@quasar/app-vite
Version:
Quasar Framework App CLI with Vite
436 lines (349 loc) • 13.3 kB
JavaScript
import { readFileSync } from 'node:fs'
import { join, isAbsolute } from 'node:path'
import { pathToFileURL } from 'node:url'
import { createServer, createServerModuleRunner } from 'vite'
import chokidar from 'chokidar'
import debounce from 'lodash/debounce.js'
import serialize from 'serialize-javascript'
import { green } from 'kolorist'
import { AppDevserver } from '../../app-devserver.js'
import { getPackage } from '../../utils/get-package.js'
import { openBrowser } from '../../utils/open-browser.js'
import { log, warn, info, dot, progress } from '../../utils/logger.js'
import { entryPointMarkup, getDevSsrTemplateFn } from '../../utils/html-template.js'
import { quasarSsrConfig } from './ssr-config.js'
import { injectPwaManifest, buildPwaServiceWorker } from '../pwa/utils.js'
const doubleSlashRE = /\/\//g
const autoRemove = 'document.currentScript.remove()'
function logServerMessage (title, msg, additional) {
log()
info(`${ msg }${ additional !== void 0 ? ` ${ green(dot) } ${ additional }` : '' }`, title)
}
let renderSSRError = null
let vueRenderToString = null
function renderError ({ err, req, res }) {
log()
warn(req.url, 'Render failed')
renderSSRError({ err, req, res })
}
function renderStoreState (ssrContext) {
const nonce = ssrContext.nonce !== void 0
? ` nonce="${ ssrContext.nonce }"`
: ''
const state = serialize(ssrContext.state, { isJSON: true })
return `<script${ nonce }>window.__INITIAL_STATE__=${ state };${ autoRemove }</script>`
}
export class QuasarModeDevserver extends AppDevserver {
#webserver = null
#viteClient = null
#viteWatcherList = []
#webserverWatcher = null
/**
* @type {{
* port: number;
* publicPath: string;
* resolveUrlPath: import('../../../types').SsrMiddlewareResolve['urlPath'];
* render: (ssrContext: import('../../../types').QSsrContext) => Promise<string>;
* }}
*/
#appOptions = {}
// also update pwa-devserver.js when changing here
#pwaManifestWatcher
#pwaServiceWorkerWatcher
#pathMap = {}
constructor (opts) {
super(opts)
const { appPaths } = this.ctx
const publicFolder = appPaths.resolve.app('public')
this.#pathMap = {
rootFolder: appPaths.appDir,
publicFolder,
templatePath: appPaths.resolve.app('index.html'),
serverFile: appPaths.resolve.entry('compiled-dev-webserver.js'),
serverEntryFile: appPaths.resolve.entry('server-entry.js'),
resolvePublicFolder () {
const dir = join(...arguments)
return isAbsolute(dir) === true
? dir
: join(publicFolder, dir)
}
}
this.registerDiff('webserver', (quasarConf, diffMap) => [
quasarConf.ssr.extendSSRWebserverConf,
// extends 'esbuild' diff
...diffMap.esbuild(quasarConf)
])
this.registerDiff('viteSSR', (quasarConf, diffMap) => [
quasarConf.ssr.pwa,
quasarConf.ssr.pwa === true ? quasarConf.pwa.swFilename : '',
// extends 'vite' diff
...diffMap.vite(quasarConf)
])
// also update pwa-devserver.js when changing here
this.registerDiff('pwaManifest', quasarConf => [
quasarConf.pwa.injectPwaMetaTags,
quasarConf.pwa.manifestFilename,
quasarConf.pwa.extendManifestJson,
quasarConf.pwa.useCredentialsForManifestTag
])
// also update pwa-devserver.js when changing here
this.registerDiff('pwaServiceWorker', quasarConf => [
quasarConf.pwa.workboxMode,
quasarConf.pwa.swFilename,
quasarConf.build,
quasarConf.pwa.workboxMode === 'GenerateSW'
? quasarConf.pwa.extendGenerateSWOptions
: [
quasarConf.pwa.extendInjectManifestOptions,
quasarConf.pwa.extendPWACustomSWConf,
quasarConf.sourceFiles.pwaServiceWorker,
quasarConf.ssr.pwaOfflineHtmlFilename
]
])
}
run (quasarConf, __isRetry) {
const { diff, queue } = super.run(quasarConf, __isRetry)
if (quasarConf.ssr.pwa === true) {
// also update pwa-devserver.js when changing here
if (diff('pwaManifest', quasarConf) === true) {
return queue(() => this.#compilePwaManifest(quasarConf))
}
// also update pwa-devserver.js when changing here
if (diff('pwaServiceWorker', quasarConf) === true) {
return queue(() => this.#compilePwaServiceWorker(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('viteSSR', quasarConf) === true) {
return queue(() => this.#runVite(quasarConf, diff('viteUrl', quasarConf)))
}
}
async #compileWebserver (quasarConf, queue) {
if (this.#webserverWatcher !== null) {
await this.#webserverWatcher.close()
}
const esbuildConfig = await quasarSsrConfig.webserver(quasarConf)
await this.watchWithEsbuild('SSR Webserver', esbuildConfig, () => {
queue(() => this.#bootWebserver(quasarConf))
}).then(esbuildCtx => {
this.#webserverWatcher = {
close: () => {
this.#webserverWatcher = null
return esbuildCtx.dispose()
}
}
})
}
async #runVite (quasarConf, urlDiffers) {
await this.clearWatcherList(this.#viteWatcherList, () => { this.#viteWatcherList = [] })
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
}
this.#appOptions.port = quasarConf.devServer.port
const publicPath = this.#appOptions.publicPath = quasarConf.build.publicPath
this.#appOptions.resolveUrlPath = publicPath === '/'
? url => url || '/'
: url => (url ? (publicPath + url).replace(doubleSlashRE, '/') : publicPath)
const viteClient = this.#viteClient = await createServer(await quasarSsrConfig.viteClient(quasarConf))
this.#viteWatcherList.push({
close: () => {
this.#viteClient = null
return viteClient.close()
}
})
const viteServer = await createServer(await quasarSsrConfig.viteServer(quasarConf))
this.#viteWatcherList.push(viteServer)
if (quasarConf.ssr.pwa === true) {
injectPwaManifest(quasarConf, true)
}
let renderTemplate
const updateTemplate = () => {
renderTemplate = getDevSsrTemplateFn(
readFileSync(this.#pathMap.templatePath, 'utf-8'),
quasarConf
)
}
updateTemplate()
this.#viteWatcherList.push(
chokidar.watch(this.#pathMap.templatePath)
.on('change', updateTemplate)
)
const viteModuleRunner = createServerModuleRunner(viteServer.environments.ssr)
this.#viteWatcherList.push(viteModuleRunner)
this.#appOptions.render = async ssrContext => {
const startTime = Date.now()
const onRenderedList = []
Object.assign(ssrContext, {
_meta: {},
onRendered: fn => { onRenderedList.push(fn) }
})
try {
const renderApp = await viteModuleRunner.import(this.#pathMap.serverEntryFile)
const app = await renderApp.default(ssrContext)
const runtimePageContent = await vueRenderToString(app, ssrContext)
onRenderedList.forEach(fn => { fn() })
// maintain compatibility with some well-known Vue plugins
// like @vue/apollo-ssr:
typeof ssrContext.rendered === 'function' && ssrContext.rendered()
if (ssrContext.state !== void 0 && quasarConf.ssr.manualStoreSerialization !== true) {
ssrContext._meta.headTags = renderStoreState(ssrContext) + ssrContext._meta.headTags
}
let html = renderTemplate(ssrContext)
html = await viteClient.transformIndexHtml(ssrContext.req.url, html, ssrContext.req.url)
html = html.replace(
entryPointMarkup,
`<div id="q-app">${ runtimePageContent }</div>`
)
logServerMessage('Rendered', ssrContext.req.url, `${ Date.now() - startTime }ms`)
return html
}
catch (err) {
viteServer.ssrFixStacktrace(err)
throw err
}
}
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('Booting Webserver...')
if (this.#webserver !== null) {
await this.#webserver.close()
}
const { create, listen, close, injectMiddlewares, serveStaticContent } = await import(
pathToFileURL(this.#pathMap.serverFile) + '?t=' + Date.now()
)
const { publicPath } = this.#appOptions
const { resolvePublicFolder } = this.#pathMap
const middlewareParams = {
port: this.#appOptions.port,
resolve: {
urlPath: this.#appOptions.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: renderError
}
// vite devmiddleware modifies req.url to account for publicPath
// but we'll break usage in the webserver if we do so
app.use((req, res, next) => {
if (this.#viteClient === null) {
next()
return
}
const { url } = req
this.#viteClient.middlewares.handle(req, res, err => {
req.url = url
next(err)
})
})
await injectMiddlewares(middlewareParams)
publicPath !== '/' && app.use((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.https) {
const https = await import('node:https')
middlewareParams.devHttpsApp = https.createServer(quasarConf.devServer.https, app)
}
middlewareParams.listenResult = await listen(middlewareParams)
this.#webserver = {
close: () => {
this.#webserver = null
return close(middlewareParams)
}
}
done('Webserver is ready')
this.printBanner(quasarConf)
this.#viteClient?.ws.send({ type: 'full-reload' })
}
// also update pwa-devserver.js when changing here
#compilePwaManifest (quasarConf) {
if (this.#pwaManifestWatcher !== void 0) {
this.#pwaManifestWatcher.close()
}
function inject () {
injectPwaManifest(quasarConf)
log(`Generated the PWA manifest file (${ quasarConf.pwa.manifestFilename })`)
}
this.#pwaManifestWatcher = chokidar.watch(
quasarConf.metaConf.pwaManifestFile,
{ ignoreInitial: true }
).on('change', debounce(() => {
inject()
this.#viteClient?.ws.send({ type: 'full-reload' })
}, 550))
inject()
}
// also update pwa-devserver.js when changing here
async #compilePwaServiceWorker (quasarConf, queue) {
if (this.#pwaServiceWorkerWatcher) {
await this.#pwaServiceWorkerWatcher.close()
}
const workboxConfig = await quasarSsrConfig.workbox(quasarConf)
if (quasarConf.pwa.workboxMode === 'InjectManifest') {
const esbuildConfig = await quasarSsrConfig.customSw(quasarConf)
await this.watchWithEsbuild('InjectManifest Custom SW', esbuildConfig, () => {
queue(() => buildPwaServiceWorker(quasarConf, workboxConfig))
}).then(esbuildCtx => {
this.#pwaServiceWorkerWatcher = { close: esbuildCtx.dispose }
})
}
await buildPwaServiceWorker(quasarConf, workboxConfig)
}
}