在 NestJS 的服务中渲染 hbs 模板

Render hbs template inside a service in NestJS

我需要从 HBS 模板生成 HTML 内容并将此内容传递到 puppeteer 页面并导出 PDF。

@UseGuards(JwtAuthGuard)
  @Get('minimal')
  async minimal(
    @Param('id') projectId: string,
    @Res() res: Response
  ) {
    const project = await this.projectService.get(projectId);
    const articles = await this.articleService.getAll(projectId);

    const buffer: Buffer = await this.katalog.minimal(res, project, articles);

    res.set({
      'Content-Type': 'application/pdf',
      'Content-Disposition': 'attachment; filename=katalog.pdf',
      'Content-Length': buffer.length,
      'Cache-Control': 'no-cache, no-store, must-revalidate',
      'Pragma': 'no-cache',
      'Expires': 0
    });

    res.end(buffer);
  }

我的服务是这样的:

@Injectable()
export class KatalogService {

  async minimal(res: Response, project: Project, articles: Article[]): Promise<Buffer> {
    const browser = await puppeteer.launch();
    const page = await browser.newPage();
    const content = await this.render(res, project, articles);

    await page.setContent(content);

    const pdfBuffer = await page.pdf();

    await page.close();
    await browser.close();

    return pdfBuffer;
  }

  async render(res: Response, project: Project, articles: Article[]): Promise<string> {
    return new Promise((resolve, reject) => {
      res.render('katalog', {}, (err, html) => {
        if(err) return reject(err);
        return resolve(html);
      })
    });
  }

}

我的代码工作正常,我只是想知道是否有任何方法可以在不将 res 对象作为参数传递的情况下实现这一点。

注意!!!

我不使用 puppeter 生成 PDF(无法使其在 WSL2 上运行),但您可以通过简单地更改 RenderPDFInterceptor.generatePDFAttachment 方法来完成!!!

如果你想尝试代码但有些东西不起作用,请随时在评论中问我。

添加拦截器的自定义 RenderPDF 装饰器(版本 1):

用法(app.controller.ts):

RenderPDF 添加到任何处理程序。此处理程序必须 return PDFRenderOptions.

import { Controller, Get } from '@nestjs/common';
import { PDFRenderOptions, RenderPDF } from './render-pdf.decorator';

@Controller()
export class AppController {
  @Get('/file')
  @RenderPDF()
  getFile(): PDFRenderOptions {
    return {
      templateFilename: 'template',
      attachmentFilename: 'file.pdf',
      locals: { message: 'Hi' }
    }
  }
}

实施(render-pdf.decorator.ts):

import { CallHandler, ExecutionContext, Injectable, NestInterceptor, StreamableFile, UseInterceptors } from '@nestjs/common';
import { Observable, mergeMap } from 'rxjs';
import { create as generatePDF } from 'html-pdf';
import { Application, Request } from 'express';


type NodeCallback<T> = (err: Error, val: T) => void;
type PromisifyCallback<T> = (cb: NodeCallback<T>) => void

const promise = <T>(cb: PromisifyCallback<T>) => {
  return new Promise<T>((res, rej) => cb((err, val) => err ? rej(err) : res(val) ));
}


export interface PDFRenderOptions {
  templateFilename: string;
  attachmentFilename: string;
  locals?: Record<string, any>;
}

@Injectable()
class RenderPDFInterceptor implements NestInterceptor {
  intercept(ctx: ExecutionContext, next: CallHandler): Observable<any> {
    const { app } = ctx.switchToHttp().getRequest() as Request;
    return next.handle().pipe(mergeMap((opts: PDFRenderOptions) => this.generatePDFAttachment(app, opts)));
  }

  private async generatePDFAttachment(app: Application, options: PDFRenderOptions) {
    const html = await promise<string>((cb) => app.render(options.templateFilename, options.locals, cb));
    const pdf = await promise<Buffer>((cb) => generatePDF(html).toBuffer(cb));
    return new StreamableFile(pdf, { disposition: `attachment; filename="${options.attachmentFilename}"` })
  }
}


export const RenderPDF = () => {
  return UseInterceptors(RenderPDFInterceptor);
}

添加拦截器的自定义 RenderPDF 装饰器 + 自定义 AddRenderPDFOptions 装饰器(版本 2)

用法(app.controller.ts)

RenderPDF 应用于某些处理程序或整个控制器,并在那里传递一些默认选项。现在您可以使用 AddRenderPDFOptions 装饰器来更改 controller-level 选项 and/or return 选项从处理程序更改 handler-level 选项。

import { Controller, Get } from '@nestjs/common';
import { PDFRenderOptions, RenderPDF, AddRenderPDFOptions } from './render-pdf.interceptor';


@Controller()
@RenderPDF({
  templateFilename: 'default-template',
  attachmentFilename: 'default-filename.pdf',
  locals: { message: 'DEFAULT MESSAGE' },
})
export class AppController {
  @Get('/default')
  getDefaultFile() {
    return {};
  }

  @Get('/different')
  @AddRenderPDFOptions({
    templateFilename: 'different-template',
    locals: { message: 'DIFFERENT MESSAGE' }
  })
  getDifferentFile(): PDFRenderOptions {
    return {
      attachmentFilename: Math.random() > 0.5
        ? 'first-different-filename.pdf'
        : 'second-different-filename.pdf',
    }
  }

  @Get('/random')
  @AddRenderPDFOptions({
    templateFilename: 'template-for-random',
  })
  getRandomFile() {
    return {
      locals: { message: Math.random().toString() }
    }
  }
}


@Controller()
export class DifferentAppController {
  @Get('something-but-not-pdf')
  getSomethingButNotPDF() {
    return { something: true };
  }

  @Get('pdf')
  @RenderPDF({ templateFilename: 'pdf', attachmentFilename: 'pdf.pdf' })
  getPDF(): PDFRenderOptions {
    return { locals: { message: "pdf" } }
  }
}

实施:

import { applyDecorators, CallHandler, ExecutionContext, Injectable, NestInterceptor, SetMetadata, StreamableFile, UseInterceptors } from '@nestjs/common';
import { Observable, mergeMap } from 'rxjs';
import { create as generatePDF } from 'html-pdf';
import { Application, Request } from 'express';
import { Reflector } from '@nestjs/core';

type NodeCallback<T> = (err: Error, val: T) => void;
type PromisifyCallback<T> = (cb: NodeCallback<T>) => void

const promise = <T>(cb: PromisifyCallback<T>) => {
  return new Promise<T>((res, rej) => cb((err, val) => err ? rej(err) : res(val) ));
}

const PDF_RENDER_OPTIONS_METADATA_KEY = Symbol('PDF render options');


export interface PDFRenderOptions {
  templateFilename?: string;
  attachmentFilename?: string;
  locals?: Record<string, any>;
}

@Injectable()
class RenderPDFInterceptor implements NestInterceptor {
  constructor(private reflector: Reflector) {}

  intercept(ctx: ExecutionContext, next: CallHandler): Observable<any> {
    const { app } = ctx.switchToHttp().getRequest() as Request;


    return next.handle().pipe(
      mergeMap((responseOptions: PDFRenderOptions) => (
        this.generatePDFAttachment(app, this.getFinalRenderOptions(ctx, responseOptions))
      ))
    );
  }

  private async generatePDFAttachment(app: Application, options: PDFRenderOptions) {
    console.log("FINAL OPTIONS", options)
    const html = await promise<string>((cb) => app.render(options.templateFilename, options.locals, cb));
    const pdf = await promise<Buffer>((cb) => generatePDF(html).toBuffer(cb));
    return new StreamableFile(pdf, { disposition: `attachment; filename="${options.attachmentFilename}"` })
  }

  private getFinalRenderOptions(ctx: ExecutionContext, responseOptions: PDFRenderOptions) {
    const controllerOptions = this.reflector.get<PDFRenderOptions>(PDF_RENDER_OPTIONS_METADATA_KEY, ctx.getClass()) || {};
    const handlerOptions = this.reflector.get<PDFRenderOptions>(PDF_RENDER_OPTIONS_METADATA_KEY, ctx.getHandler()) || {};

    return {
      ...controllerOptions,
      ...handlerOptions,
      ...responseOptions,
      locals: {
        ...controllerOptions.locals,
        ...handlerOptions.locals,
        ...responseOptions.locals,
      }
    }
  }
}

export const AddRenderPDFOptions = (options: PDFRenderOptions = {}) => {
  return SetMetadata(PDF_RENDER_OPTIONS_METADATA_KEY, { locals: {}, ...options });
}

export const RenderPDF = (options: PDFRenderOptions = {}) => {
  return applyDecorators(
    AddRenderPDFOptions(options),
    UseInterceptors(RenderPDFInterceptor),
  )
}