[core] Convert GOWS Lottie stickers (application/was) to animated WebP - fix #2039 closes devlikeapro/waha-plus#43

Lottie stickers arrive from WhatsApp as a ZIP archive (mimetype
application/was) containing animation/animation.json. This change
intercepts those stickers in the GOWS Plus session, renders each
animation frame off-screen, and delivers an animated WebP to the
webhook instead of the raw ZIP.

Implementation:
- src/plus/utils/lottie-converter.ts (new):
  - Extracts animation.json from the ZIP via adm-zip
  - Renders frames with @lottiefiles/dotlottie-web + @napi-rs/canvas
    (HTMLCanvasElement polyfill required; ImageData must NOT be polyfilled
    to avoid per-frame pixel caching in WASM memory)
  - Drives frames manually via dotLottie.setFrame(i) after 'load' for
    reliable synchronous capture
  - Encodes each RGBA frame to single-frame WebP with sharp (quality=80)
  - Assembles animated WebP via node-webpmux with full alpha transparency
  - Frame delay derived from animation fps; clamped to >=30ms
  - Fallback delay configurable via WAHA_LOTTIE_DEFAULT_DELAY_MS env var
  - sharp/adm-zip/node-webpmux loaded via require() — their module.exports
    is the callable itself with no .default, so ESM default import resolves
    to undefined at runtime

- src/plus/engines/gows/session.gows.plus.ts:
  - Overrides downloadMedia with LottieAwareGOWSEngineMediaProcessor
  - normalizeStickerUrl: fixes GOWS URL field case mismatch (URL vs url)
    that caused an infinite network loop on Lottie downloads
  - Reports mimetype image/webp and extension .webp for Lottie stickers

- package.json: add @lottiefiles/dotlottie-web, @napi-rs/canvas,
  node-webpmux to dependencies
This commit is contained in:
Berg Pinheiro authored and devlikepro committed 2026-05-26 12:21:18 +07:00
1 parent dfe0a3c8c4
commit 4ff1c01e38
8 files changed
+522 -4

No files matched your search

+3
View File
@@ -39,7 +39,9 @@
"@casl/ability": "^6.8.0",
"@figuro/chatwoot-sdk": "^1.1.17",
"@liaoliaots/nestjs-redis": "^9",
"@lottiefiles/dotlottie-web": "^0.72.1",
"@modelcontextprotocol/sdk": "^1.29.0",
"@napi-rs/canvas": "^1.0.0",
"@nestjs/axios": "^3.0.2",
"@nestjs/bullmq": "^11.0.2",
"@nestjs/common": "^11.0.0",
@@ -94,6 +96,7 @@
"mustache": "^4.2.0",
"nestjs-pino": "^4.1.0",
"node-cache": "5.1.2",
"node-webpmux": "^3.2.1",
"passport": "^0.7.0",
"passport-headerapikey": "^1.2.2",
"pg": "^8.13.1",
+14 -1
View File
@@ -39,6 +39,7 @@ import {
NotImplementedByEngineError,
} from '@waha/core/exceptions';
import { IMediaEngineProcessor } from '@waha/core/media/IMediaEngineProcessor';
import { LottieMediaProcessorWrapper } from '@waha/core/media/LottieMediaProcessorWrapper';
import { QR } from '@waha/core/QR';
import { ExtractMessageKeysForRead } from '@waha/core/utils/convertors';
import { parseMessageIdSerialized } from '@waha/core/utils/ids';
@@ -2224,7 +2225,10 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
}
protected async downloadMedia(message) {
const processor = new GOWSEngineMediaProcessor(this);
let processor: IMediaEngineProcessor<any> = new GOWSEngineMediaProcessor(
this,
);
processor = new LottieMediaProcessorWrapper(processor, this.logger);
const media = await this.mediaManager.processMedia(
processor,
message,
@@ -2574,6 +2578,15 @@ export class GOWSEngineMediaProcessor implements IMediaEngineProcessor<any> {
async getMediaBuffer(message: any): Promise<Buffer | null> {
const mediaDownloadTimeoutMs = 600_000; // 10 minutes
// Go serializes stickerMessage.URL as uppercase (Go struct field name), but
// whatsmeow's download unmarshals it expecting lowercase proto JSON. Add the
// lowercase alias so the download URL is found correctly.
const sticker = message?.Message?.stickerMessage;
if (sticker?.URL && !sticker?.url) {
message = JSON.parse(JSON.stringify(message));
message.Message.stickerMessage.url = message.Message.stickerMessage.URL;
}
const data = JSON.stringify(message.Message);
const tmpdir = new TmpDir(
this.session.logger,
+6 -1
View File
@@ -66,6 +66,7 @@ import { toVcardV3 } from '@waha/core/vcard';
import { createAgentProxy } from '@waha/core/helpers.proxy';
import type { Agent } from 'https';
import { IMediaEngineProcessor } from '@waha/core/media/IMediaEngineProcessor';
import { LottieMediaProcessorWrapper } from '@waha/core/media/LottieMediaProcessorWrapper';
import { QR } from '@waha/core/QR';
import { AckToStatus, StatusToAck } from '@waha/core/utils/acks';
import { pairs } from '@waha/utils/pairs';
@@ -2770,7 +2771,11 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
}
protected async downloadMedia(message): Promise<WAMedia | null> {
const processor = new NOWEBEngineMediaProcessor(this, this.loggerBuilder);
let processor: IMediaEngineProcessor<any> = new NOWEBEngineMediaProcessor(
this,
this.loggerBuilder,
);
processor = new LottieMediaProcessorWrapper(processor, this.logger);
return this.mediaManager.processMedia(processor, message, this.name);
}
+3 -1
View File
@@ -33,6 +33,7 @@ import {
NotImplementedByEngineError,
} from '@waha/core/exceptions';
import { IMediaEngineProcessor } from '@waha/core/media/IMediaEngineProcessor';
import { LottieMediaProcessorWrapper } from '@waha/core/media/LottieMediaProcessorWrapper';
import { QR } from '@waha/core/QR';
import { StatusToAck } from '@waha/core/utils/acks';
import {
@@ -2224,7 +2225,8 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
}
protected async downloadMedia(message: Message) {
const processor = new WEBJSEngineMediaProcessor();
let processor = new WEBJSEngineMediaProcessor();
processor = new LottieMediaProcessorWrapper(processor, this.logger);
const media = await this.mediaManager.processMedia(
processor,
message,
+3 -1
View File
@@ -126,6 +126,7 @@ import {
NotImplementedByEngineError,
} from '@waha/core/exceptions';
import { IMediaEngineProcessor } from '@waha/core/media/IMediaEngineProcessor';
import { LottieMediaProcessorWrapper } from '@waha/core/media/LottieMediaProcessorWrapper';
import { IWPPAuthManager } from '@waha/core/engines/wpp/IWPPAuthManager';
import { QR } from '@waha/core/QR';
import { removeSingletonFiles } from '@waha/core/utils/chrome';
@@ -2118,7 +2119,8 @@ export class WhatsappSessionWPPCore extends WhatsappSession {
}
protected async downloadMedia(message: any): Promise<WAMedia | null> {
const processor = new WPPEngineMediaProcessor(this.wpp);
let processor = new WPPEngineMediaProcessor(this.wpp);
processor = new LottieMediaProcessorWrapper(processor, this.logger);
return this.mediaManager.processMedia(processor, message, this.name);
}
@@ -0,0 +1,69 @@
import { IMediaEngineProcessor } from '@waha/core/media/IMediaEngineProcessor';
import { convertLottieZipToWebp } from '@waha/core/utils/lottie-converter';
const LOTTIE_MIMETYPE = 'application/was';
/**
* Wraps any engine media processor to transparently convert Lottie stickers
* (mimetype `application/was`, delivered as a ZIP archive) into animated WebP.
* The inner processor downloads the raw ZIP; this wrapper converts it before
* the result reaches the media storage layer, so callers see `image/webp`.
*/
export class LottieMediaProcessorWrapper implements IMediaEngineProcessor<any> {
constructor(
private inner: IMediaEngineProcessor<any>,
private logger: any,
) {}
hasMedia(msg: any): boolean {
return this.inner.hasMedia(msg);
}
getChatId(msg: any): string {
return this.inner.getChatId(msg);
}
getMessageId(msg: any): string {
return this.inner.getMessageId(msg);
}
getMimetype(msg: any): string {
const mime = this.inner.getMimetype(msg);
return mime === LOTTIE_MIMETYPE ? 'image/webp' : mime;
}
getFilename(msg: any): string | null {
if (this.inner.getMimetype(msg) !== LOTTIE_MIMETYPE) {
return this.inner.getFilename(msg);
}
return `${this.inner.getMessageId(msg)}.webp`;
}
async getMediaBuffer(msg: any): Promise<Buffer | null> {
const buffer = await this.inner.getMediaBuffer(msg);
if (!buffer || !buffer.length) {
return null;
}
if (this.inner.getMimetype(msg) !== LOTTIE_MIMETYPE) {
return buffer;
}
const id = this.inner.getMessageId(msg);
this.logger.info(
`Converting Lottie sticker '${id}' to WebP (input ${buffer.length} bytes)...`,
);
try {
const webp = await convertLottieZipToWebp(buffer);
this.logger.info(
`Lottie sticker '${id}' converted to WebP (${webp.byteLength} bytes)`,
);
return webp;
} catch (err) {
this.logger.warn(
{ err: err },
`Lottie conversion failed for '${id}', returning original buffer`,
);
return buffer;
}
}
}
+284
View File
@@ -0,0 +1,284 @@
import { createCanvas, Canvas } from '@napi-rs/canvas';
import { DotLottie } from '@lottiefiles/dotlottie-web';
// sharp, adm-zip and node-webpmux are CJS modules whose module.exports is the
// callable/constructor itself — static default imports resolve to undefined at
// runtime because there is no .default property. require() is the correct interop.
// eslint-disable-next-line @typescript-eslint/no-require-imports
const sharp = require('sharp');
// eslint-disable-next-line @typescript-eslint/no-require-imports
const AdmZip = require('adm-zip');
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { Image: WebpImage } = require('node-webpmux');
// DotLottie._draw() guards canvas rendering behind:
// canvas instanceof HTMLCanvasElement || canvas instanceof OffscreenCanvas
// Both are undefined in Node.js, so the guard fails and _draw() returns without
// calling putImageData – producing a fully black canvas. Registering the
// @napi-rs/canvas Canvas constructor as HTMLCanvasElement makes the instanceof
// check pass so DotLottie acquires the 2D context and paints frames normally.
//
// ImageData must NOT be registered globally. When it is, DotLottie uses:
// new ImageData(Uint8ClampedArray_view_into_WASM_memory, w, h)
// @napi-rs/canvas copies the bytes once at construction and never re-reads the
// WASM buffer, so every frame produces the same cached pixels. Without a global
// ImageData, DotLottie falls back to:
// ctx.createImageData(w, h) + imageData.data.set(currentPixelBuffer)
// which copies fresh pixel data from the WASM buffer on every frame.
const globalAny = global as Record<string, unknown>;
if (!globalAny['HTMLCanvasElement']) {
globalAny['HTMLCanvasElement'] = Canvas;
}
export class LottieConvertError extends Error {
constructor(message: string, options?: { cause?: unknown }) {
super(message);
this.name = 'LottieConvertError';
if (options?.cause !== undefined) {
(this as any).cause = options.cause;
}
}
}
const STICKER_SIZE = 256;
const envDelayMs = parseInt(
process.env['WAHA_LOTTIE_DEFAULT_DELAY_MS'] ?? '',
10,
);
const DEFAULT_DELAY_MS = envDelayMs > 0 ? envDelayMs : 100;
function isZipBuffer(buffer: Buffer): boolean {
return (
buffer.length >= 4 &&
buffer[0] === 0x50 &&
buffer[1] === 0x4b &&
buffer[2] === 0x03 &&
buffer[3] === 0x04
);
}
function extractAnimationJson(zipBuffer: Buffer): string | null {
const zip = new AdmZip(zipBuffer);
const entry =
zip.getEntry('animation/animation.json') ??
zip.getEntries().find((e) => e.entryName.endsWith('.json'));
if (!entry) {
return null;
}
return zip.readAsText(entry);
}
export async function convertLottieZipToWebp(
zipBuffer: Buffer,
): Promise<Buffer> {
if (!isZipBuffer(zipBuffer)) {
throw new LottieConvertError('Lottie buffer is not a valid ZIP');
}
let animationJson: string | null;
try {
animationJson = extractAnimationJson(zipBuffer);
} catch (err) {
throw new LottieConvertError(`Failed to unzip Lottie file: ${err}`, {
cause: err,
});
}
if (!animationJson) {
throw new LottieConvertError('Lottie ZIP has no animation.json entry');
}
let animationData: Record<string, unknown>;
try {
animationData = JSON.parse(animationJson);
} catch (err) {
throw new LottieConvertError(
`Lottie animation.json is not valid JSON: ${err}`,
{
cause: err,
},
);
}
return renderLottieToWebp(animationData);
}
function renderLottieToWebp(
animationData: Record<string, unknown>,
): Promise<Buffer> {
return new Promise<Buffer>((resolve, reject) => {
let settled = false;
function finish(result: Buffer) {
if (settled) return;
settled = true;
resolve(result);
}
function fail(reason: unknown) {
if (settled) return;
settled = true;
reject(
reason instanceof LottieConvertError
? reason
: new LottieConvertError(String(reason), { cause: reason }),
);
}
let canvas: Canvas;
let dotLottie: DotLottie;
try {
canvas = createCanvas(STICKER_SIZE, STICKER_SIZE);
} catch (err) {
return fail(err);
}
// DotLottie dispatches 'load' via setTimeout. Instead of relying on the
// setImmediate-based animation loop (timing-dependent, unreliable for
// frame capture), we drive frames manually with setFrame(i) after load.
// setFrame() renders synchronously: set_frame → render → putImageData.
try {
dotLottie = new DotLottie({
loop: false,
useFrameInterpolation: false,
autoplay: false,
canvas: canvas as unknown as HTMLCanvasElement,
data: animationData,
renderConfig: {
autoResize: false,
devicePixelRatio: 1,
freezeOnOffscreen: false,
},
});
} catch (err) {
return fail(err);
}
function onLoad() {
if (settled) return;
const totalFrames = dotLottie.totalFrames;
const duration = dotLottie.duration;
let delayMs: number;
let framePixels: Buffer[];
try {
if (totalFrames <= 0) {
throw new Error(
`Animation has no frames (totalFrames=${totalFrames})`,
);
}
let rawDelayMs = DEFAULT_DELAY_MS;
if (duration > 0 && isFinite(duration)) {
const fps = totalFrames / duration;
const computed = Math.round(1000 / fps);
if (computed > 0 && isFinite(computed)) {
rawDelayMs = computed;
}
}
// WebP delay is in milliseconds. Clamp to at least 30ms to stay above
// the ~20ms threshold where some players impose their own minimum.
// When clamping, subsample frames proportionally so total duration is
// preserved — e.g. a 60fps animation must not play 1.8× too slowly
// because all 180 frames are kept at 30ms each.
delayMs = Math.max(30, rawDelayMs);
const frameStep = delayMs / rawDelayMs;
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('canvas.getContext("2d") returned null');
}
// Collect raw RGBA pixels. When frameStep > 1 we subsample frames to
// keep playback speed correct despite the minimum-delay clamp.
// Pass the float position to setFrame so the renderer interpolates
// smoothly along the animation curve rather than snapping to the
// nearest integer frame (which causes uneven motion).
framePixels = [];
for (let t = 0; t < totalFrames; t += frameStep) {
// setFrame(0) is a no-op (already rendered after load), so skip it.
if (t > 0) {
dotLottie.setFrame(t);
}
framePixels.push(
Buffer.from(
ctx.getImageData(0, 0, STICKER_SIZE, STICKER_SIZE).data,
),
);
}
} catch (err) {
dotLottie.destroy();
fail(err);
return;
}
dotLottie.destroy();
// Encode each RGBA frame as a single-frame WebP, then assemble into an
// animated WebP via node-webpmux. This preserves full alpha transparency
// and avoids GIF's 256-colour palette limitation.
if (framePixels.length === 1) {
sharp(framePixels[0], {
raw: { width: STICKER_SIZE, height: STICKER_SIZE, channels: 4 },
})
.webp({ quality: 80, effort: 3 })
.toBuffer()
.then(finish)
.catch(fail);
return;
}
Promise.all(
framePixels.map((px) =>
sharp(px, {
raw: { width: STICKER_SIZE, height: STICKER_SIZE, channels: 4 },
})
.webp({ quality: 80, effort: 3 })
.toBuffer(),
),
)
.then((webpFrames) =>
Promise.all(
webpFrames.map((buf) =>
WebpImage.generateFrame({
buffer: buf,
delay: delayMs,
x: 0,
y: 0,
blend: false,
dispose: true,
}),
),
),
)
.then((frames) =>
WebpImage.save(null, {
width: STICKER_SIZE,
height: STICKER_SIZE,
bgColor: [0, 0, 0, 0],
loops: 0,
frames: frames,
}),
)
.then(finish)
.catch(fail);
}
function onLoadError(payload: any) {
if (settled) return;
dotLottie.destroy();
fail(
new LottieConvertError(
`DotLottie failed to load animation: ${
payload?.error ?? 'unknown error'
}`,
{ cause: payload?.error },
),
);
}
dotLottie.addEventListener('load', onLoad);
dotLottie.addEventListener('loadError', onLoadError);
});
}
+140
View File
@@ -2250,6 +2250,13 @@ __metadata:
languageName: node
linkType: hard
"@lottiefiles/dotlottie-web@npm:^0.72.1":
version: 0.72.1
resolution: "@lottiefiles/dotlottie-web@npm:0.72.1"
checksum: 10/79bec5037fb07f38a676d32a11597277c0b15c8ad9716dbde008fe79b2e4e0374aaefcffd9bb68672a8bb35d02b57af4ed01a857238166f8ed85786ad29756b1
languageName: node
linkType: hard
"@lukeed/csprng@npm:^1.0.0":
version: 1.1.0
resolution: "@lukeed/csprng@npm:1.1.0"
@@ -2367,6 +2374,129 @@ __metadata:
languageName: node
linkType: hard
"@napi-rs/canvas-android-arm64@npm:1.0.0":
version: 1.0.0
resolution: "@napi-rs/canvas-android-arm64@npm:1.0.0"
conditions: os=android & cpu=arm64
languageName: node
linkType: hard
"@napi-rs/canvas-darwin-arm64@npm:1.0.0":
version: 1.0.0
resolution: "@napi-rs/canvas-darwin-arm64@npm:1.0.0"
conditions: os=darwin & cpu=arm64
languageName: node
linkType: hard
"@napi-rs/canvas-darwin-x64@npm:1.0.0":
version: 1.0.0
resolution: "@napi-rs/canvas-darwin-x64@npm:1.0.0"
conditions: os=darwin & cpu=x64
languageName: node
linkType: hard
"@napi-rs/canvas-linux-arm-gnueabihf@npm:1.0.0":
version: 1.0.0
resolution: "@napi-rs/canvas-linux-arm-gnueabihf@npm:1.0.0"
conditions: os=linux & cpu=arm
languageName: node
linkType: hard
"@napi-rs/canvas-linux-arm64-gnu@npm:1.0.0":
version: 1.0.0
resolution: "@napi-rs/canvas-linux-arm64-gnu@npm:1.0.0"
conditions: os=linux & cpu=arm64 & libc=glibc
languageName: node
linkType: hard
"@napi-rs/canvas-linux-arm64-musl@npm:1.0.0":
version: 1.0.0
resolution: "@napi-rs/canvas-linux-arm64-musl@npm:1.0.0"
conditions: os=linux & cpu=arm64 & libc=musl
languageName: node
linkType: hard
"@napi-rs/canvas-linux-riscv64-gnu@npm:1.0.0":
version: 1.0.0
resolution: "@napi-rs/canvas-linux-riscv64-gnu@npm:1.0.0"
conditions: os=linux & cpu=riscv64 & libc=glibc
languageName: node
linkType: hard
"@napi-rs/canvas-linux-x64-gnu@npm:1.0.0":
version: 1.0.0
resolution: "@napi-rs/canvas-linux-x64-gnu@npm:1.0.0"
conditions: os=linux & cpu=x64 & libc=glibc
languageName: node
linkType: hard
"@napi-rs/canvas-linux-x64-musl@npm:1.0.0":
version: 1.0.0
resolution: "@napi-rs/canvas-linux-x64-musl@npm:1.0.0"
conditions: os=linux & cpu=x64 & libc=musl
languageName: node
linkType: hard
"@napi-rs/canvas-win32-arm64-msvc@npm:1.0.0":
version: 1.0.0
resolution: "@napi-rs/canvas-win32-arm64-msvc@npm:1.0.0"
conditions: os=win32 & cpu=arm64
languageName: node
linkType: hard
"@napi-rs/canvas-win32-x64-msvc@npm:1.0.0":
version: 1.0.0
resolution: "@napi-rs/canvas-win32-x64-msvc@npm:1.0.0"
conditions: os=win32 & cpu=x64
languageName: node
linkType: hard
"@napi-rs/canvas@npm:^1.0.0":
version: 1.0.0
resolution: "@napi-rs/canvas@npm:1.0.0"
dependencies:
"@napi-rs/canvas-android-arm64": "npm:1.0.0"
"@napi-rs/canvas-darwin-arm64": "npm:1.0.0"
"@napi-rs/canvas-darwin-x64": "npm:1.0.0"
"@napi-rs/canvas-linux-arm-gnueabihf": "npm:1.0.0"
"@napi-rs/canvas-linux-arm64-gnu": "npm:1.0.0"
"@napi-rs/canvas-linux-arm64-musl": "npm:1.0.0"
"@napi-rs/canvas-linux-riscv64-gnu": "npm:1.0.0"
"@napi-rs/canvas-linux-x64-gnu": "npm:1.0.0"
"@napi-rs/canvas-linux-x64-musl": "npm:1.0.0"
"@napi-rs/canvas-win32-arm64-msvc": "npm:1.0.0"
"@napi-rs/canvas-win32-x64-msvc": "npm:1.0.0"
dependenciesMeta:
"@napi-rs/canvas-android-arm64":
optional: true
"@napi-rs/canvas-darwin-arm64":
optional: true
"@napi-rs/canvas-darwin-x64":
optional: true
"@napi-rs/canvas-linux-arm-gnueabihf":
optional: true
"@napi-rs/canvas-linux-arm64-gnu":
optional: true
"@napi-rs/canvas-linux-arm64-musl":
optional: true
"@napi-rs/canvas-linux-riscv64-gnu":
optional: true
"@napi-rs/canvas-linux-x64-gnu":
optional: true
"@napi-rs/canvas-linux-x64-musl":
optional: true
"@napi-rs/canvas-win32-arm64-msvc":
optional: true
"@napi-rs/canvas-win32-x64-msvc":
optional: true
canvas:
built: true
skia-canvas:
built: true
checksum: 10/1b2838833988c98d86105980ccd52fb53c4059e30732c7bebdf21774d83a351bf389337c8e6eb4d55fc31359accb90e27d8d80c94cc3bb08d415ab0586227464
languageName: node
linkType: hard
"@nestjs/axios@npm:^3.0.2":
version: 3.1.3
resolution: "@nestjs/axios@npm:3.1.3"
@@ -10498,6 +10628,13 @@ __metadata:
languageName: node
linkType: hard
"node-webpmux@npm:^3.2.1":
version: 3.2.1
resolution: "node-webpmux@npm:3.2.1"
checksum: 10/8797ebe4846707443629800c70d95c592ab1c18f313b360dd26832b8443efa9b89966269ad6b1e3aa59a18e790406b29a55e19c10212fc3068dc476e48cee6e9
languageName: node
linkType: hard
"nopt@npm:^5.0.0":
version: 5.0.0
resolution: "nopt@npm:5.0.0"
@@ -13795,7 +13932,9 @@ __metadata:
"@grpc/grpc-js": "npm:^1.14.1"
"@grpc/proto-loader": "npm:^0.8.0"
"@liaoliaots/nestjs-redis": "npm:^9"
"@lottiefiles/dotlottie-web": "npm:^0.72.1"
"@modelcontextprotocol/sdk": "npm:^1.29.0"
"@napi-rs/canvas": "npm:^1.0.0"
"@nestjs/axios": "npm:^3.0.2"
"@nestjs/bullmq": "npm:^11.0.2"
"@nestjs/cli": "npm:^11.0.0"
@@ -13864,6 +14003,7 @@ __metadata:
mustache: "npm:^4.2.0"
nestjs-pino: "npm:^4.1.0"
node-cache: "npm:5.1.2"
node-webpmux: "npm:^3.2.1"
oxlint: "npm:^1.13.0"
passport: "npm:^0.7.0"
passport-headerapikey: "npm:^1.2.2"