@quasar/app-vite
Version:
Quasar Framework App CLI with Vite
548 lines (456 loc) • 15.9 kB
JavaScript
import { readFileSync } from 'node:fs'
import { join, isAbsolute } from 'node:path'
import { pathToFileURL } from 'node:url'
import { createServer, createServerModuleRunner } from 'vite'
import { watch as chokidarWatch } 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
)
}
/** @type {import('@quasar/render-ssr-error').default} */
let renderSSRError = null
let vueRenderToString = null
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
/** @type {import('vite').ViteDevServer|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(
chokidarWatch(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:
if (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)
const url = ssrContext.url || ssrContext.req.url
const originalUrl = ssrContext.originalUrl || ssrContext.req.originalUrl
html = await viteClient.transformIndexHtml(url, html, originalUrl)
html = html.replace(
entryPointMarkup,
`<div id="q-app">${runtimePageContent}</div>`
)
logServerMessage('Rendered', 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,
injectDevMiddleware = ({ app }) =>
middleware =>
app.use(middleware),
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,
devHttpsOptions: quasarConf.devServer.https,
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: ({ 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)
await registerDevMiddleware((req, res, next) => {
if (this.#viteClient === null) {
next()
return
}
// Vite dev middleware modifies req.url to account for publicPath
// but we'll break usage in the webserver if we do so
const { url } = req
this.#viteClient.middlewares.handle(req, res, err => {
req.url = url
next(err)
})
})
await injectMiddlewares(middlewareParams)
if (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.https) {
middlewareParams.devHttpsApp = await this.#createLazyDevHttpsServer(
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' })
}
/**
* 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
*/
async #createLazyDevHttpsServer(httpsOptions, app) {
const { createServer: createHttpsServer } = await import('node:https')
const createInstance = () => {
try {
return createHttpsServer(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 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 = chokidarWatch(
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)
}
}