请注意,生成的 /src-ssr 目录中包含一个名为 server.js 的文件。该文件定义了 SSR web 服务器的创建、管理和服务方式。你可以选择监听一个端口,也可以为 serverless 基础设施提供一个处理函数,完全由你决定。
结构解析
/src-ssr/server.js 文件是一个简单的 JavaScript/TypeScript 文件,用于启动 SSR web 服务器,并定义服务器如何启动、处理请求以及导出内容(如果需要导出的话)。
WARNING
/src-ssr/server.js 文件在开发环境和生产环境中都会被使用,因此请谨慎配置。要区分这两种状态,可以使用 import.meta.env.QUASAR_DEV 和 import.meta.env.QUASAR_PROD。
下面来看看它的内容,先是 JS 项目的版本,然后是 TypeScript 的版本。根据你选择的 web 服务器框架来选择对应的示例:
Javascript
/**
* Runs in Node.js context.
*
* Make sure to pnpm/yarn/npm/bun install (in /src-ssr folder)
* anything you import here.
*/
import { lstatSync } from 'node:fs'
import { Hono } from 'hono'
import { serve } from '@hono/node-server'
import { serveStatic } from '@hono/node-server/serve-static'
import {
defineSsrClose,
defineSsrCreate,
defineSsrInjectDevMiddleware,
defineSsrListen,
defineSsrRenderPreloadTag,
defineSsrServeStaticContent
} from '#q-app'
/**
* Create your webserver and return its instance.
*/
export const create = defineSsrCreate(async (/* { ... } */) => {
const app = new Hono()
if (import.meta.env.QUASAR_PROD) {
const { compress } = await import('hono/compress')
app.use(compress())
}
return app
})
/**
* Used by Quasar SSR dev server to inject middleware into the webserver.
* It uses it to handle Vite dev server, handle public paths, etc.
* The given middleware is compatible with `node:http`'s Server, Express, Connect, etc.
*
* Can be async: defineSsrInjectDevMiddleware(async ({ app }) => { ... })
*/
export const injectDevMiddleware = defineSsrInjectDevMiddleware(
({ app }) =>
middleware => {
app.use('*', async (c, next) => {
const req = c.env.incoming
const res = c.env.outgoing
const { promise, resolve, reject } = Promise.withResolvers()
const onDone = () => resolve(false)
res.once('finish', onDone)
res.once('close', onDone)
middleware(req, res, err => {
res.off('finish', onDone)
res.off('close', onDone)
if (err) reject(err)
else resolve(true)
})
const passed = await promise
if (passed) {
/**
* Vite skipped the request, so we let Hono continue down the chain
*/
return next()
}
/**
* Vite handled the request natively!
*
* Monkey-patch the native Node.js response methods.
* The Hono Node adapter will still try to write headers and end the stream
* when we return the dummy response. We neutralize these methods
* so it silently does nothing instead of crashing.
*/
const noop = () => res
res.writeHead = noop
res.setHeader = noop
res.end = noop
/**
* Return a dummy Response.
* This satisfies Hono's strict requirement that every branch
* either returns a Response or calls `await next()`.
*/
return new Response(null)
})
}
)
/**
* You need to make the server listen to the indicated port
* and return the listening instance or whatever you need to
* close the server with.
*
* The "listenResult" param for the "close()" definition below
* is what you return here.
*
* For production, you can instead export your
* handler for serverless use or whatever else fits your needs.
*/
export const listen = defineSsrListen(
async ({ app, devHttpsOptions, port }) => {
const opts = {
fetch: app.fetch,
port
}
/**
* For production HTTPS you can use the /src-ssr/server-assets folder
* to place your certificates and then read them here to create the server.
*
* Use resolve.serverAssets('path-to-file') to get the absolute path to the file
* or directly play with folders.serverAssets.
*/
if (import.meta.env.QUASAR_DEV && devHttpsOptions) {
const { createServer } = await import('node:https')
opts.createServer = createServer
opts.serverOptions = { ...devHttpsOptions }
} else {
const { createServer } = await import('node:http')
opts.createServer = createServer
}
return serve(opts, info => {
if (import.meta.env.QUASAR_PROD) {
console.log(`🚀 Server listening at port ${info.port}`)
}
})
}
)
/**
* Should close the server and free up any resources.
* Will be used on development only when the server needs
* to be rebooted.
*
* Should you need the result of the "listen()" call above,
* you can use the "listenResult" param.
*
* Can be async: defineSsrClose(async ({ listenResult }) => { ... })
*/
export const close = defineSsrClose(({ listenResult }) => listenResult.close())
const maxAge = import.meta.env.QUASAR_DEV ? 0 : 1000 * 60 * 60 * 24 * 30
/**
* Should return a function that will be used to configure the webserver
* to serve static content at "urlPath" from "pathToServe" folder/file.
*
* Notice resolve.urlPath(urlPath) and resolve.public(pathToServe) usages.
*
* Can be async: defineSsrServeStaticContent(async ({ app, resolve }) => {
* Can return an async function: return async ({ urlPath = '/', pathToServe = '.', opts = {} }) => {
*/
export const serveStaticContent = defineSsrServeStaticContent(
({ app, resolve }) =>
({ urlPath, pathToServe, opts = {} }) => {
const pubPath = resolve.public(pathToServe)
const isDir = lstatSync(pubPath).isDirectory()
const resolvedUrlPath = resolve.urlPath(urlPath)
const routePath = isDir
? resolvedUrlPath.endsWith('*')
? resolvedUrlPath
: `${resolvedUrlPath}*`
: resolvedUrlPath
const { maxAge: localMaxAge, ...serveOpts } = opts
const cacheAge = localMaxAge ?? maxAge
if (cacheAge > 0) {
app.get(routePath, async (c, next) => {
c.header('Cache-Control', `public, max-age=${cacheAge}`)
await next()
})
}
app.use(
routePath,
serveStatic({
[isDir ? 'root' : 'path']: pubPath,
...serveOpts
})
)
}
)
const jsRE = /\.js$/
const cssRE = /\.css$/
const woffRE = /\.woff$/
const woff2RE = /\.woff2$/
const gifRE = /\.gif$/
const jpgRE = /\.jpe?g$/
const pngRE = /\.png$/
/**
* Should return a String with HTML output
* (if any) for preloading indicated file
*/
export const renderPreloadTag = defineSsrRenderPreloadTag(
(file /* , { ssrContext } */) => {
if (jsRE.test(file)) {
return `<link rel="modulepreload" href="${file}" crossorigin>`
}
if (cssRE.test(file)) {
return `<link rel="stylesheet" href="${file}" crossorigin>`
}
if (woffRE.test(file)) {
return `<link rel="preload" href="${file}" as="font" type="font/woff" crossorigin>`
}
if (woff2RE.test(file)) {
return `<link rel="preload" href="${file}" as="font" type="font/woff2" crossorigin>`
}
if (gifRE.test(file)) {
return `<link rel="preload" href="${file}" as="image" type="image/gif" crossorigin>`
}
if (jpgRE.test(file)) {
return `<link rel="preload" href="${file}" as="image" type="image/jpeg" crossorigin>`
}
if (pngRE.test(file)) {
return `<link rel="preload" href="${file}" as="image" type="image/png" crossorigin>`
}
return ''
}
)TypeScript
/**
* Runs in Node.js context.
*
* Make sure to pnpm/yarn/npm/bun install (in /src-ssr folder)
* anything you import here.
*/
import { lstatSync } from "node:fs";
import { Hono } from "hono";
import type { IncomingMessage, ServerResponse } from "node:http";
import { serve } from "@hono/node-server";
import { serveStatic } from "@hono/node-server/serve-static";
import {
defineSsrClose,
defineSsrCreate,
defineSsrInjectDevMiddleware,
defineSsrListen,
defineSsrRenderPreloadTag,
defineSsrServeStaticContent
} from "#q-app";
interface NodeEnv {
Bindings: {
incoming: IncomingMessage;
outgoing: ServerResponse;
};
}
declare module "#q-app" {
interface SsrDriver {
app: Hono<NodeEnv>;
listenResult: ReturnType<typeof serve>;
request: IncomingMessage;
response: ServerResponse;
}
}
/**
* Create your webserver and return its instance.
*/
export const create = defineSsrCreate(async (/* { ... } */) => {
const app = new Hono<NodeEnv>();
if (import.meta.env.QUASAR_PROD) {
const { compress } = await import("hono/compress");
app.use(compress());
}
return app;
});
/**
* Used by Quasar SSR dev server to inject middleware into the webserver.
* It uses it to handle Vite dev server, handle public paths, etc.
* The given middleware is compatible with `node:http`'s Server, Express, Connect, etc.
*
* Can be async: defineSsrInjectDevMiddleware(async ({ app }) => { ... })
*/
export const injectDevMiddleware = defineSsrInjectDevMiddleware(
({ app }) =>
middleware => {
app.use("*", async (c, next) => {
const req = c.env.incoming;
const res = c.env.outgoing;
const { promise, resolve, reject } = Promise.withResolvers<boolean>();
const onDone = () => {
resolve(false);
};
res.once("finish", onDone);
res.once("close", onDone);
middleware(req, res, err => {
res.off("finish", onDone);
res.off("close", onDone);
if (err) reject(err);
else resolve(true);
});
const passed: boolean = await promise;
if (passed) {
/**
* Vite skipped the request, so we let Hono continue down the chain
*/
return next();
}
/**
* Vite handled the request natively!
*
* Monkey-patch the native Node.js response methods.
* The Hono Node adapter will still try to write headers and end the stream
* when we return the dummy response. We neutralize these methods
* so it silently does nothing instead of crashing.
*/
const noop = () => res;
res.writeHead = noop;
res.setHeader = noop;
res.end = noop;
/**
* Return a dummy Response.
* This satisfies Hono's strict requirement that every branch
* either returns a Response or calls `await next()`.
*/
return new Response(null);
});
}
);
/**
* You need to make the server listen to the indicated port
* and return the listening instance or whatever you need to
* close the server with.
*
* The "listenResult" param for the "close()" definition below
* is what you return here.
*
* For production, you can instead export your
* handler for serverless use or whatever else fits your needs.
*/
export const listen = defineSsrListen(
async ({ app, devHttpsOptions, port }) => {
const opts: Parameters<typeof serve>[0] = {
fetch: app.fetch,
port
};
/**
* For production HTTPS you can use the /src-ssr/server-assets folder
* to place your certificates and then read them here to create the server.
*
* Use resolve.serverAssets('path-to-file') to get the absolute path to the file
* or directly play with folders.serverAssets.
*/
if (import.meta.env.QUASAR_DEV && devHttpsOptions) {
const { createServer } = await import("node:https");
opts.createServer = createServer;
opts.serverOptions = { ...devHttpsOptions };
} else {
const { createServer } = await import("node:http");
opts.createServer = createServer;
}
return serve(opts, info => {
if (import.meta.env.QUASAR_PROD) {
console.log(`🚀 Server listening at port ${info.port}`);
}
});
}
);
/**
* Should close the server and free up any resources.
* Will be used on development only when the server needs
* to be rebooted.
*
* Should you need the result of the "listen()" call above,
* you can use the "listenResult" param.
*
* Can be async: defineSsrClose(async ({ listenResult }) => { ... })
*/
export const close = defineSsrClose(({ listenResult }) => listenResult.close());
const maxAge = import.meta.env.QUASAR_DEV ? 0 : 1000 * 60 * 60 * 24 * 30;
/**
* Should return a function that will be used to configure the webserver
* to serve static content at "urlPath" from "pathToServe" folder/file.
*
* Notice resolve.urlPath(urlPath) and resolve.public(pathToServe) usages.
*
* Can be async: defineSsrServeStaticContent(async ({ app, resolve }) => {
* Can return an async function: return async ({ urlPath = '/', pathToServe = '.', opts = {} }) => {
*/
export const serveStaticContent = defineSsrServeStaticContent(
({ app, resolve }) =>
({ urlPath, pathToServe, opts = {} }) => {
const pubPath = resolve.public(pathToServe);
const isDir = lstatSync(pubPath).isDirectory();
const resolvedUrlPath = resolve.urlPath(urlPath);
const routePath = isDir
? resolvedUrlPath.endsWith("*")
? resolvedUrlPath
: `${resolvedUrlPath}*`
: resolvedUrlPath;
const { maxAge: maxAgeOpt, ...serveOpts } = opts;
const cacheAge = maxAgeOpt !== void 0 ? maxAgeOpt : maxAge;
if (cacheAge > 0) {
app.get(routePath, async (c, next) => {
c.header("Cache-Control", `public, max-age=${cacheAge}`);
await next();
});
}
const staticOpts: Parameters<typeof serveStatic>[0] = { ...serveOpts };
if (isDir) {
staticOpts.root = pubPath;
} else {
staticOpts.path = pubPath;
}
app.use(routePath, serveStatic(staticOpts));
}
);
const jsRE = /\.js$/;
const cssRE = /\.css$/;
const woffRE = /\.woff$/;
const woff2RE = /\.woff2$/;
const gifRE = /\.gif$/;
const jpgRE = /\.jpe?g$/;
const pngRE = /\.png$/;
/**
* Should return a String with HTML output
* (if any) for preloading indicated file
*/
export const renderPreloadTag = defineSsrRenderPreloadTag(
(file /* , { ssrContext } */) => {
if (jsRE.test(file)) {
return `<link rel="modulepreload" href="${file}" crossorigin>`;
}
if (cssRE.test(file)) {
return `<link rel="stylesheet" href="${file}" crossorigin>`;
}
if (woffRE.test(file)) {
return `<link rel="preload" href="${file}" as="font" type="font/woff" crossorigin>`;
}
if (woff2RE.test(file)) {
return `<link rel="preload" href="${file}" as="font" type="font/woff2" crossorigin>`;
}
if (gifRE.test(file)) {
return `<link rel="preload" href="${file}" as="image" type="image/gif" crossorigin>`;
}
if (jpgRE.test(file)) {
return `<link rel="preload" href="${file}" as="image" type="image/jpeg" crossorigin>`;
}
if (pngRE.test(file)) {
return `<link rel="preload" href="${file}" as="image" type="image/png" crossorigin>`;
}
return "";
}
);Serverless
当将 Quasar SSR 应用部署到 serverless 架构时,你面临一个主要的架构转变:Serverless 环境不支持长时间运行的进程。
通常,Quasar 的 SSR 构建会启动一个 Node.js web 服务器并监听特定端口(例如 app.listen(3000))。在 serverless 环境中,你必须跳过这个监听阶段。取而代之的是,你的入口脚本(由 Quasar CLI 构建生成)必须导出一个无状态的请求处理函数,供 serverless 提供商在每次 HTTP 请求到来时调用。
由于构建后的 SSR 服务器本质上是一个 Hono/Express/Fastify 等应用,你的目标是将其导出为特定云服务商能够理解的格式。
我们将使用 /quasar.config > ssr > prodScriptNamedExport 属性来配置生产环境生成的 dist/index.js 文件导出什么内容:
/**
* The named exports to use for the production generated SSR index.js script.
* Works with `false` (no named exports), a single string (one named export),
* or an array of strings (multiple named exports).
*
* Useful for serverless environments where you might want to export the
* handler function. It creates one or more named exports from the
* object returned by the defineSsrListen() function in /src-ssr/server file.
*
* @default false
*
* @example
* prodScriptNamedExport: ['handler', 'ssr']
* export const listen = defineSsrListen(() => {
* if (import.meta.env.QUASAR_PROD) {
* return { handler, ssr }
* }
* })
*
* This will generate an SSR index.js with the following exports:
* const { handler, ssr } = await listen({...})
* export { handler, ssr }
*
* @example
* prodScriptNamedExport: 'default'
* export const listen = defineSsrListen(({ app }) => {
* if (import.meta.env.QUASAR_PROD) {
* return { default: app }
* }
* })
*
* This will generate an SSR index.js with the following exports:
* const listenResult = await listen({...})
* export default listenResult?.default
*
* @example
* prodScriptNamedExport: 'app'
* export const listen = defineSsrListen(({ app }) => {
* if (import.meta.env.QUASAR_PROD) {
* return { app }
* }
* })
*
* This will generate an SSR index.js with the following exports:
* const { app } = await listen({...})
* export { app }
*
* @example 'renderSsrContext' (special case)
*
* This will generate an SSR index.js with the following export:
* export { render as renderSsrContext }
* where "render" is the same function used in
* the /src-ssr/middlewares/render file
*/
prodScriptNamedExport?: false | string | string[];以下是一些主流 serverless 供应商的示例:
AWS Lambda(通过 Serverless Framework 或 AWS SAM)
AWS Lambda 期望一个具有 (event, context) 签名的处理函数。由于 Quasar 输出的是一个 Node.js web 服务器应用,你不能直接将其传递给 Lambda。你需要一个像 serverless-http 这样的包装库来桥接 Lambda 的事件对象和 web 服务器的请求/响应对象。
// file: /src-ssr/server
export const listen = defineSsrListen(async ({ app }) => {
if (import.meta.env.QUASAR_PROD) {
// Crucial step: we don't listen on any port
const { default: serverless } = await import('serverless-http')
return {
// Example with Express.js;
// Adapt to your chosen webserver
handler: serverless(app)
}
}
// ...
})
// file: /quasar.config
ssr: {
prodScriptNamedExport: 'handler'
}记得在 /src-ssr 中将 serverless-http 安装为 “dependencies”(而不是 “devDependencies”)。
Firebase Cloud Functions
Firebase Functions 构建在 Google Cloud Functions 之上。
// file: /src-ssr/server
export const listen = defineSsrListen(async ({ app }) => {
if (import.meta.env.QUASAR_PROD) {
// Crucial step: we don't listen on any port
const { default: functions } = await import('firebase-functions')
return {
// Example with Express.js;
// Adapt to your chosen webserver
ssr: functions.https.onRequest(app)
}
}
// ...
})
// file: /quasar.config
ssr: {
prodScriptNamedExport: 'ssr'
}记得在 /src-ssr 中将 firebase-functions 安装为 “dependencies”(而不是 “devDependencies”)。
Vercel
Vercel 的 Node.js 运行时原生支持标准的 Node HTTP 请求监听器(接受 req 和 res 参数的函数)。
// file: /src-ssr/server
export const listen = defineSsrListen(async ({ app }) => {
if (import.meta.env.QUASAR_PROD) {
// Crucial step: we don't listen on any port
return {
// Example with Express.js;
// Adapt to your chosen webserver
default: app
}
}
// ...
})
// file: /quasar.config
ssr: {
prodScriptNamedExport: 'default'
}Netlify Functions
Netlify Functions 的工作方式与 AWS Lambda 类似(底层就是由 AWS Lambda 驱动的)。和 AWS 一样,你需要 serverless-http 来包装你的应用。
// file: /src-ssr/server
export const listen = defineSsrListen(async ({ app }) => {
if (import.meta.env.QUASAR_PROD) {
// Crucial step: we don't listen on any port
const { default: serverless } = await import('serverless-http')
return {
// Example with Express.js;
// Adapt to your chosen webserver
handler: serverless(app)
}
}
// ...
})
// file: /quasar.config
ssr: {
prodScriptNamedExport: 'handler'
}记得在 /src-ssr 中将 serverless-http 安装为 “dependencies”(而不是 “devDependencies”)。
Azure Functions
// file: /src-ssr/server
export const listen = defineSsrListen(async ({ app }) => {
if (import.meta.env.QUASAR_PROD) {
// Crucial step: we don't listen on any port
// Example with Express.js;
// Adapt to your chosen webserver
const { createHandler } = await import('azure-function-express')
return {
default: createHandler(app)
}
}
// ...
})
// file: /quasar.config
ssr: {
prodScriptNamedExport: 'default'
}记得在 /src-ssr 中将 azure-function-express 安装为 “dependencies”(而不是 “devDependencies”)。
DigitalOcean Functions
// file: /src-ssr/server
export const listen = defineSsrListen(async ({ app }) => {
if (import.meta.env.QUASAR_PROD) {
// Crucial step: we don't listen on any port
const { default: serverless } = await import('serverless-http')
return {
// Example with Express.js;
// Adapt to your chosen webserver
main: serverless(app)
}
}
// ...
})
// file: /quasar.config
ssr: {
prodScriptNamedExport: 'main'
}记得在 /src-ssr 中将 serverless-http 安装为 “dependencies”(而不是 “devDependencies”)。
腾讯云
// file: /src-ssr/server
export const listen = defineSsrListen(async ({ app }) => {
if (import.meta.env.QUASAR_PROD) {
// Crucial step: we don't listen on any port
const { default: serverless } = await import('serverless-http')
return {
// Example with Express.js;
// Adapt to your chosen webserver
main_handler: serverless(app)
}
}
// ...
})
// file: /quasar.config
ssr: {
prodScriptNamedExport: 'main_handler'
}记得在 /src-ssr 中将 serverless-http 安装为 “dependencies”(而不是 “devDependencies”)。