为什么捐赠
API 浏览器
联系站长
Quasar CLI with Vite - @quasar/app-vite
SSR Webserver

生成的 /src-ssr 目录中有一个 server.js 文件。这个文件定义了您的 SSR web 服务器如何创建、管理和运行。您可以选择监听一个端口,也可以为 serverless 基础设施提供一个 handler,完全由您决定。

结构解析

/src-ssr/server.js 是一个简单的 JavaScript/TypeScript 文件,它负责启动您的 SSR web 服务器,定义服务器如何启动、处理请求以及导出内容(如果需要导出的话)。

WARNING

/src-ssr/server.js 在开发环境和生产环境中都会运行,所以请小心配置它。可以使用 process∙env∙DEVprocess∙env∙PROD 来区分不同的环境。

/**
 * 运行在 Node.js 上下文中。
 */

/**
 * 确保在项目根目录下通过 yarn/npm/pnpm/bun install 安装了
 * 此处导入的所有依赖(express 和 compression 除外)。
 */
import express from "express";
import compression from "compression";
import {
  defineSsrCreate,
  defineSsrListen,
  defineSsrClose,
  defineSsrServeStaticContent,
  defineSsrRenderPreloadTag,
} from "#q-app/wrappers";

/**
 * 创建您的 web 服务器并返回其实例。
 *
 * 可以是异步的:defineSsrCreate(async ({ ... }) => { ... })
 *
 * 参数:({
 *   port, // 开发环境:devServer 端口;生产环境:process.env.PORT 或 quasar.config > ssr > prodPort
 *   devHttpsOptions, // 仅开发环境,使用 HTTPS 时可用;如果使用自定义服务器,可以用它来自行处理 HTTPS,而不使用 listen() 中的 devHttpsApp
 *   resolve: {
 *      urlPath, // (url) => 确保包含 publicPath 的路径字符串,
 *      root, // (pathPart1, ...pathPartN) => 拼接到根目录的路径字符串,
 *      public // (pathPart1, ...pathPartN) => 拼接到 public 目录的路径字符串
 *   },
 *   publicPath, // string
 *   folders: {
 *     root, // 根目录的路径字符串
 *     public // public 目录的路径字符串
 *   },
 *   render // (ssrContext) => html 字符串
 * })
 */
export const create = defineSsrCreate((/* { ... } */) => {
  const app = express();

  // 攻击者可以利用此响应头来检测运行 Express 的应用,
  // 然后发起有针对性的攻击
  app.disable("x-powered-by");

  // 将所有需要最先运行的中间件放在这里
  if (process.env.PROD) {
    app.use(compression());
  }

  return app;
});

/**
 * 供 Quasar SSR 开发服务器使用,用于向 web 服务器注入中间件。
 * 它用来处理 Vite 开发服务器、public 路径等。
 * 给定的中间件兼容 `node:http` 的 Server、Express、Connect 等。
 *
 * 可以是异步的:defineSsrInjectDevMiddleware(async ({ app }) => { ... })
 */
export const injectDevMiddleware = defineSsrInjectDevMiddleware(({ app }) => {
  return (middleware) => {
    app.use(middleware);
  };
});

/**
 * 您需要让服务器监听指定的端口,
 * 并返回监听实例或其他用于关闭服务器的对象。
 *
 * 下面 "close()" 定义中的 "listenResult" 参数
 * 就是您在此处返回的内容。
 *
 * 在生产环境下,您也可以导出一个用于 serverless 的 handler,
 * 或任何适合您需求的内容。
 *
 * 可以是异步的:defineSsrListen(async ({ app, devHttpsApp, port }) => { ... })
 *
 * 参数:({
 *   app, // Express app 或 create() 返回的任何对象
 *   devHttpsApp, // 仅开发环境,使用 HTTPS 时可用;Node.js HTTPS 服务器实例
 *   devHttpsOptions, // 仅开发环境,使用 HTTPS 时可用;如果使用自定义服务器,可以用它来自行处理 HTTPS
 *   port, // 开发环境:devServer 端口;生产环境:process.env.PORT 或 quasar.config > ssr > prodPort
 *   resolve: {
 *      urlPath, // (url) => 确保包含 publicPath 的路径字符串,
 *      root, // (pathPart1, ...pathPartN) => 拼接到根目录的路径字符串,
 *      public // (pathPart1, ...pathPartN) => 拼接到 public 目录的路径字符串
 *   },
 *   publicPath, // string
 *   folders: {
 *     root, // 根目录的路径字符串
 *     public // public 目录的路径字符串
 *   },
 *   render, // (ssrContext) => html 字符串
 *   serve: {
 *     static, // ({ urlPath = '/', pathToServe = '.', opts = {} }) => void(或 serveStaticContent() 的返回值)
 *     error // 仅开发环境;({ err, req, res }) => void
 *   },
 * })
 */
export const listen = defineSsrListen(({ app, devHttpsApp, port }) => {
  const server = devHttpsApp || app;
  return server.listen(port, () => {
    if (process.env.PROD) {
      console.log("Server listening at port " + port);
    }
  });
});

/**
 * 关闭服务器并释放所有资源。
 * 仅在开发环境下服务器需要重启时使用。
 *
 * 如果需要使用上面 "listen()" 的返回结果,
 * 可以使用 "listenResult" 参数。
 *
 * 可以是异步的:defineSsrClose(async ({ listenResult }) => { ... })
 *
 * 参数:({
 *   app, // Express app 或 create() 返回的任何对象
 *   devHttpsApp, // 仅开发环境,使用 HTTPS 时可用
 *   port, // 开发环境:devServer 端口;生产环境:process.env.PORT 或 quasar.config > ssr > prodPort
 *   resolve: {
 *      urlPath, // (url) => 确保包含 publicPath 的路径字符串,
 *      root, // (pathPart1, ...pathPartN) => 拼接到根目录的路径字符串,
 *      public // (pathPart1, ...pathPartN) => 拼接到 public 目录的路径字符串
 *   },
 *   publicPath, // string
 *   folders: {
 *     root, // 根目录的路径字符串
 *     public // public 目录的路径字符串
 *   },
 *   serve: {
 *     static, // ({ urlPath = '/', pathToServe = '.', opts = {} }) => void(或 serveStaticContent() 的返回值)
 *     error // 仅开发环境;({ err, req, res }) => void
 *   },
 *   render, // (ssrContext) => html 字符串
 *   listenResult // listen() 的返回值
 * })
 */
export const close = defineSsrClose(({ listenResult }) => {
  return listenResult.close();
});

const maxAge = process.env.DEV ? 0 : 1000 * 60 * 60 * 24 * 30;

/**
 * 应返回一个函数,用于配置 web 服务器
 * 从 "pathToServe" 文件夹/文件向 "urlPath" 路径提供静态内容服务。
 *
 * 注意 resolve.urlPath(urlPath) 和 resolve.public(pathToServe) 的用法。
 *
 * 可以是异步的:defineSsrServeStaticContent(async ({ app, resolve }) => {
 * 可以返回异步函数:return async ({ urlPath = '/', pathToServe = '.', opts = {} }) => {
 *
 * 参数:({
 *   app, // Express app 或 create() 返回的任何对象
 *   port, // 开发环境:devServer 端口;生产环境:process.env.PORT 或 quasar.config > ssr > prodPort
 *   resolve: {
 *      urlPath: (url) => 确保包含 publicPath 的路径字符串,
 *      root: (pathPart1, ...pathPartN) => 拼接到根目录的路径字符串,
 *      public: (pathPart1, ...pathPartN) => 拼接到 public 目录的路径字符串
 *   },
 *   publicPath, // string
 *   folders: {
 *     root, // 根目录的路径字符串
 *     public // public 目录的路径字符串
 *   },
 *   render: (ssrContext) => html 字符串
 * })
 */
export const serveStaticContent = defineSsrServeStaticContent(({ app, resolve }) => {
  return ({ urlPath = "/", pathToServe = ".", opts = {} }) => {
    const serveFn = express.static(resolve.public(pathToServe), { maxAge, ...opts });
    app.use(resolve.urlPath(urlPath), serveFn);
  };
});

const jsRE = /\.js$/;
const cssRE = /\.css$/;
const woffRE = /\.woff$/;
const woff2RE = /\.woff2$/;
const gifRE = /\.gif$/;
const jpgRE = /\.jpe?g$/;
const pngRE = /\.png$/;

/**
 * 应返回一个 HTML 字符串(如果有的话),
 * 用于预加载指定的文件
 */
export const renderPreloadTag = defineSsrRenderPreloadTag((file /* , { ssrContext } */) => {
  if (jsRE.test(file) === true) {
    return `<link rel="modulepreload" href="${file}" crossorigin>`;
  }

  if (cssRE.test(file) === true) {
    return `<link rel="stylesheet" href="${file}" crossorigin>`;
  }

  if (woffRE.test(file) === true) {
    return `<link rel="preload" href="${file}" as="font" type="font/woff" crossorigin>`;
  }

  if (woff2RE.test(file) === true) {
    return `<link rel="preload" href="${file}" as="font" type="font/woff2" crossorigin>`;
  }

  if (gifRE.test(file) === true) {
    return `<link rel="preload" href="${file}" as="image" type="image/gif" crossorigin>`;
  }

  if (jpgRE.test(file) === true) {
    return `<link rel="preload" href="${file}" as="image" type="image/jpeg" crossorigin>`;
  }

  if (pngRE.test(file) === true) {
    return `<link rel="preload" href="${file}" as="image" type="image/png" crossorigin>`;
  }

  return "";
});

TIP

请记住,listen() 函数的返回值(如果有的话)将作为构建产物 dist/ssr/index.js 的导出内容。如果需要的话,您可以返回一个用于 serverless 架构的 ssrHandler。

用法

WARNING

  • 如果您从 node_modules 中导入了任何包,请确保将它安装在 package.json 的 “dependencies” 中,而不是 “devDependencies” 中。
  • 一般不需要在此文件中添加中间件(但也可以这样做)。推荐使用 SSR Middlewares 来添加中间件,这样还可以配置某些中间件只在开发或生产环境下运行。

替换 Express

您可以将默认的 Express 服务器替换为其他服务器,例如 Connect、Fastify、h3 等。您需要安装相关的包,然后相应地调整 createlistenclose 等函数。

示例:Connect

由于 Express 本身基于 Connect,您可以直接用 Connect 作为简单的替代品:


$ yarn add connect
src-ssr/server.js

import { defineSsrCreate } from "#q-app/wrappers";
import connect from "connect";
import compression from "compression";

export const create = defineSsrCreate((/* { ... } */) => {
  const app = connect();

  // 将所有需要最先运行的中间件放在这里
  if (process.env.PROD) {
    app.use(compression());
  }

  return app;
});

示例:Fastify

Fastify 与 Express 差异较大,需要更深入的适配:


$ yarn add fastify @fastify/middie @fastify/compress @fastify/static
src-ssr/server.js

import {
  defineSsrCreate,
  defineSsrInjectDevMiddleware,
  defineSsrListen,
  defineSsrClose,
  defineSsrServeStaticContent,
} from "#q-app/wrappers";
import Fastify from "fastify";

// 如果使用 TypeScript,还需要启用以下类型声明:
/*
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'

import type { Server } from 'node:http'
declare module '#q-app' {
  interface SsrDriver {
    app: FastifyInstance;
    listenResult: Server;
    request: FastifyRequest;
    response: FastifyReply;
  }
}
*/

export const create = defineSsrCreate(async ({ devHttpsOptions }) => {
  const app = Fastify({
    https: devHttpsOptions ?? null,
  });

  // 将所有需要最先运行的中间件放在这里
  if (process.env.PROD) {
    await app.register(import("@fastify/compress"));
  }

  return app;
});

export const injectDevMiddleware = defineSsrInjectDevMiddleware(async ({ app }) => {
  await app.register(import("@fastify/middie"));

  return (middleware) => {
    app.use(middleware);
  };
});

export const listen = defineSsrListen(async ({ app, port }) => {
  await app.listen({ port });
  return app.server;
});

export const close = defineSsrClose(({ listenResult }) => {
  return listenResult.close();
});

const maxAge = process.env.DEV ? 0 : 1000 * 60 * 60 * 24 * 30;

export const serveStaticContent = defineSsrServeStaticContent(({ app, resolve }) => {
  return async ({ urlPath = "/", pathToServe = ".", opts = {} }) => {
    await app.register(import("@fastify/static"), {
      root: resolve.public(pathToServe),
      prefix: resolve.urlPath(urlPath),
      maxAge: opts.maxAge ?? maxAge,
      // 避免与 ./middlewares/render 冲突
      wildcard: false,
      index: false,
    });
  };
});

// renderPreloadTag 逻辑保持不变
src-ssr/middlewares/render.js

import { defineSsrMiddleware } from "#q-app/wrappers";

export default defineSsrMiddleware(({ app, resolve, render, serve }) => {
  app.get(resolve.urlPath("*"), async (req, res) => {
    res.type("text/html");

    try {
      return await render({ req, res });
    } catch (err) {
      if (err.url) {
        return res.redirect(err.url, err.code);
      }

      if (err.code === 404) {
        return res.status(404).send("404 | Page Not Found");
      }

      if (process.env.DEV) {
        serve.error({ err, req, res });
      } else {
        res.status(500).send("500 | Internal Server Error");

        if (process.env.DEBUGGING) {
          console.error(err.stack);
        }
      }
    }
  });
});

监听一个端口

这是使用 Quasar CLI 在项目中添加 SSR 模式时的默认选项。它启动后会监听配置的端口号(process∙env∙PORT 或 quasar.config 文件 > ssr > prodPort)。

src-ssr/server.js

export const listen = defineSsrListen(({ app, devHttpsApp, port }) => {
  const server = devHttpsApp || app;
  return server.listen(port, () => {
    if (process.env.PROD) {
      console.log("Server listening at port " + port);
    }
  });
});

Serverless

如果您有一个 serverless 架构的基础设施,那么通常需要导出一个 handler 处理程序,而不是监听端口。

假设您的 serverless 服务要求您具名导出一个名为 handler 的变量,那么您需要这样做:

src-ssr/server.js

import { defineSsrListen } from "#q-app/wrappers";
export const listen = defineSsrListen(({ app, devHttpsApp, port }) => {
  if (process.env.DEV) {
    // 开发环境下,启动创建的服务器来监听端口
    const server = devHttpsApp || app;
    return server.listen(port, () => {
      // 准备好为客户端提供服务了
    });
  } else {
    // 生产环境下
    // 返回一个带有 "handler" 属性的对象,
    // 服务器脚本会将其作为具名导出
    return { handler: app };
  }
});

请注意:提供的 app 是一个 (req, res, next) => void 格式的函数。如果您需要导出一个 (event, context, callback) => void 格式的 handler,那么很可能需要使用 serverless-http 包(见下文)。

示例:serverless-http

您需要手动通过 yarn/npm 安装 serverless-http 包。

src-ssr/server.js

import { defineSsrListen } from "#q-app/wrappers";
import serverless from "serverless-http";

export const listen = defineSsrListen(({ app, devHttpsApp, port }) => {
  if (process.env.DEV) {
    // 开发环境下,启动创建的服务器来监听端口
    const server = devHttpsApp || app;
    return server.listen(port, () => {
      // 准备好为客户端提供服务了
    });
  } else {
    // 生产环境下
    return { handler: serverless(app) };
  }
});

示例:Firebase function

src-ssr/server.js

import { defineSsrListen } from "#q-app/wrappers";
import * as functions from "firebase-functions";

export const listen = defineSsrListen(({ app, devHttpsApp, port }) => {
  if (process.env.DEV) {
    // 开发环境下,启动创建的服务器来监听端口
    const server = devHttpsApp || app;
    return server.listen(port, () => {
      // 准备好为客户端提供服务了
    });
  } else {
    // 生产环境下
    return {
      handler: functions.https.onRequest(app),
    };
  }
});