HttpServer working with GET routes
This commit is contained in:
@@ -1 +1,11 @@
|
||||
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
|
||||
{
|
||||
"name": "",
|
||||
"short_name": "",
|
||||
"icons": [
|
||||
{"src": "/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png"},
|
||||
{"src": "/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png"}
|
||||
],
|
||||
"theme_color": "#ffffff",
|
||||
"background_color": "#ffffff",
|
||||
"display": "standalone"
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"@types/on-headers": "1.0.4",
|
||||
"@types/response-time": "2.3.9",
|
||||
"@types/serve-favicon": "2.5.7",
|
||||
"@types/ws": "8.18.1",
|
||||
"eslint": "9.36.0",
|
||||
"globals": "16.4.0",
|
||||
"jiti": "2.6.0",
|
||||
@@ -48,7 +49,10 @@
|
||||
"morgan": "1.10.1",
|
||||
"multer": "2.0.2",
|
||||
"on-headers": "1.1.0",
|
||||
"permessage-deflate": "0.1.7",
|
||||
"response-time": "2.3.4",
|
||||
"serve-favicon": "2.5.1"
|
||||
"serve-favicon": "2.5.1",
|
||||
"websocket-extensions": "0.1.4",
|
||||
"ws": "8.18.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export default class WebSocketServer {}
|
||||
13
Web/WebSocket/websocket/server/src/health/HealthCheck.ts
Normal file
13
Web/WebSocket/websocket/server/src/health/HealthCheck.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type {HealthStatus} from './IHealthCheck';
|
||||
|
||||
export default class HealthCheck {
|
||||
public checkHealth(): HealthStatus {
|
||||
return {
|
||||
status: 'ok',
|
||||
environment: 'development',
|
||||
app: 'websocket-server',
|
||||
version: '0.0.1',
|
||||
zone: 'us-central1'
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type {Request, Response} from 'express';
|
||||
import type IRoute from '../net/http/IRoutes';
|
||||
import IHealthCheck from './IHealthCheck';
|
||||
|
||||
export default class HealthCheckRoute implements IRoute {
|
||||
private readonly _healthCheck: IHealthCheck;
|
||||
|
||||
constructor(healthCheck: IHealthCheck) {
|
||||
this._healthCheck = healthCheck;
|
||||
}
|
||||
|
||||
public getGETRoutes() {
|
||||
console.log('[HealthCheckRoute] getGETRoutes called');
|
||||
const routes = {
|
||||
'/ok.html': (req, res) => this.externalReadiness.call(this, req, res),
|
||||
'/ping': (req, res) => this.ping.call(this, req, res)
|
||||
};
|
||||
console.log('[HealthCheckRoute] returning routes:', Object.keys(routes));
|
||||
return routes;
|
||||
}
|
||||
|
||||
public getPOSTRoutes() {
|
||||
return {};
|
||||
}
|
||||
|
||||
public getPUTRoutes() {
|
||||
return {};
|
||||
}
|
||||
|
||||
public getPATCHRoutes() {
|
||||
return {};
|
||||
}
|
||||
|
||||
public getDELETERoutes() {
|
||||
return {};
|
||||
}
|
||||
|
||||
private externalReadiness(_req: Request, res: Response) {
|
||||
console.log('[HealthCheckRoute] External readiness');
|
||||
res.setHeader('Cache-Control', 'public, max-age=0, no-cache, no-store');
|
||||
|
||||
try {
|
||||
const result = this._healthCheck.checkHealth();
|
||||
|
||||
if (!result || !result.status) {
|
||||
return res.status(500).end();
|
||||
}
|
||||
|
||||
switch (result.status) {
|
||||
case 'ok':
|
||||
case 'draining':
|
||||
case 'draining2':
|
||||
case 'disabled':
|
||||
return res.status(200).json(result);
|
||||
case 'starting':
|
||||
case 'drained':
|
||||
case 'stopped':
|
||||
return res.status(503).json(result);
|
||||
default:
|
||||
return res.status(500).json(result);
|
||||
}
|
||||
} catch {
|
||||
return res.status(500).end();
|
||||
}
|
||||
}
|
||||
|
||||
private ping(_req: Request, res: Response) {
|
||||
console.log('[HealthCheckRoute] Ping handler called from [%s]', _req.ip);
|
||||
|
||||
res.status(200).send({status: 'pong'}).end();
|
||||
}
|
||||
}
|
||||
11
Web/WebSocket/websocket/server/src/health/IHealthCheck.ts
Normal file
11
Web/WebSocket/websocket/server/src/health/IHealthCheck.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export type HealthStatus = {
|
||||
status: 'ok' | 'draining' | 'draining2' | 'disabled' | 'starting' | 'drained' | 'stopped';
|
||||
environment: string;
|
||||
app: string;
|
||||
version: string;
|
||||
zone: string;
|
||||
};
|
||||
|
||||
export default interface IHealthCheck {
|
||||
checkHealth(): HealthStatus;
|
||||
}
|
||||
@@ -1,28 +1,24 @@
|
||||
|
||||
import path from 'node:path';
|
||||
import {LoggerFactory} from '@techniker-me/logger';
|
||||
import HttpServer from './net/http/HttpServer';
|
||||
import path from 'node:path';
|
||||
const logger = LoggerFactory.getLogger('Server');
|
||||
import HealthCheckRoute from './health/HealthCheckRoute';
|
||||
import HealthCheck from './health/HealthCheck';
|
||||
|
||||
const httpServer = new HttpServer(
|
||||
'http',
|
||||
3000,
|
||||
{
|
||||
getGETRoutes: () => ({}),
|
||||
getPOSTRoutes: () => ({}),
|
||||
getPUTRoutes: () => ({}),
|
||||
getPATCHRoutes: () => ({}),
|
||||
getDELETERoutes: () => ({})
|
||||
},
|
||||
{},
|
||||
'',
|
||||
[],
|
||||
path.resolve(process.cwd(), 'assets', 'favicon', 'favicon.ico'),
|
||||
{}
|
||||
);
|
||||
const logger = LoggerFactory.getLogger('Server');
|
||||
const healthCheck = new HealthCheck();
|
||||
const healthCheckRoute = new HealthCheckRoute(healthCheck);
|
||||
|
||||
const httpServer = new HttpServer('http', 3000, healthCheckRoute, {}, '', [], path.resolve(process.cwd(), 'assets', 'favicon', 'favicon.ico'), {});
|
||||
|
||||
httpServer.on('error', () => {
|
||||
console.log('[HttpServer] Error event');
|
||||
logger.error('[HttpServer] Error');
|
||||
});
|
||||
httpServer.on('request', (method: string, url: string, statusCode: number, headers: Record<string, string>) => {
|
||||
console.log(`[HttpServer] Request: ${method} ${url} -> ${statusCode}`);
|
||||
});
|
||||
|
||||
httpServer.start();
|
||||
httpServer
|
||||
.start()
|
||||
.then(() => console.log('[Server] Server started successfully'))
|
||||
.catch(err => console.log('[Server] Server failed to start:', err));
|
||||
|
||||
@@ -163,4 +163,8 @@ export default class Assert {
|
||||
private static _isNumberInRange(value: unknown, lowerBound: number, upperBound: number): value is number {
|
||||
return Assert._isNumber(value) && value >= lowerBound && value <= upperBound;
|
||||
}
|
||||
|
||||
private constructor() {
|
||||
throw new Error('Assert is a static class that may not be instantiated');
|
||||
}
|
||||
}
|
||||
|
||||
9
Web/WebSocket/websocket/server/src/lang/Strings.ts
Normal file
9
Web/WebSocket/websocket/server/src/lang/Strings.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export default class Strings {
|
||||
public static randomString(length: number): string {
|
||||
return (Date.now().toString(36) + Math.random().toString(36).substring(2)).substring(0, length);
|
||||
}
|
||||
|
||||
private constructor() {
|
||||
throw new Error('Strings is a static class that may not be instantiated');
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import type {ILogger} from '@techniker-me/logger';
|
||||
import {LoggerFactory} from '@techniker-me/logger';
|
||||
import IRoutes from './IRoutes';
|
||||
import {Subject} from '@techniker-me/tools';
|
||||
import express from 'express';
|
||||
import express, { RequestHandler } from 'express';
|
||||
import morgan from 'morgan';
|
||||
import favicon from 'serve-favicon';
|
||||
import bodyParser from 'body-parser';
|
||||
@@ -32,7 +32,6 @@ export default class HttpServer {
|
||||
private readonly _eventEmitter: EventEmitter;
|
||||
private readonly _protocol: 'http' | 'https';
|
||||
private readonly _port: number;
|
||||
// @ts-expect-error - unused parameter for future functionality
|
||||
private readonly _routes: IRoutes;
|
||||
// @ts-expect-error - unused parameter for future functionality
|
||||
private readonly _viewsPath: object;
|
||||
@@ -47,6 +46,7 @@ export default class HttpServer {
|
||||
ttl: tlsSessionTimeout.asMilliseconds(),
|
||||
max: maxCachedTlsSessions
|
||||
});
|
||||
private _jsonHandler: Nullable<express.RequestHandler>;
|
||||
|
||||
constructor(
|
||||
protocol: 'https' | 'http',
|
||||
@@ -78,11 +78,13 @@ export default class HttpServer {
|
||||
this._app = new Subject<Nullable<express.Application>>(null);
|
||||
this._eventEmitter = new EventEmitter();
|
||||
this._server = new Subject<Nullable<Server>>(null);
|
||||
this._jsonHandler = null;
|
||||
}
|
||||
|
||||
public start() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const app = (this._app.value = express());
|
||||
this._jsonHandler = bodyParser.json({limit: requestSizeLimit});
|
||||
|
||||
this.configureListener();
|
||||
this.configureMiddleware();
|
||||
@@ -113,6 +115,10 @@ export default class HttpServer {
|
||||
server.once('error', onError);
|
||||
server.once('listening', onListen);
|
||||
|
||||
server.on('request', (req, res) => {
|
||||
this._eventEmitter.emit('request', req.method, req.url, res.statusCode, req.headers);
|
||||
});
|
||||
|
||||
server.on('newSession', (sessionId, sessionData, callback) => {
|
||||
const cacheId = sessionId.toString('hex');
|
||||
|
||||
@@ -142,7 +148,7 @@ export default class HttpServer {
|
||||
});
|
||||
}
|
||||
|
||||
public on(event: string, handler: () => void): void {
|
||||
public on(event: string, handler: (...args: unknown[]) => void): void {
|
||||
Assert.isNonEmptyString('event', event);
|
||||
Assert.isFunction('handler', handler);
|
||||
|
||||
@@ -310,9 +316,6 @@ export default class HttpServer {
|
||||
for (const resourcePath of this._resourcesPaths) {
|
||||
app.use(express.static(resourcePath));
|
||||
}
|
||||
|
||||
app.use(this.errorHandler);
|
||||
app.use(this.genericNotFoundHandler);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
@@ -325,6 +328,8 @@ export default class HttpServer {
|
||||
}
|
||||
|
||||
private genericNotFoundHandler(_req: express.Request, res: express.Response) {
|
||||
console.log('Generic not found handler');
|
||||
|
||||
res.status(404).send({status: 'not-found'});
|
||||
|
||||
return;
|
||||
@@ -335,26 +340,40 @@ export default class HttpServer {
|
||||
throw new Error('Unable to configure routes, no app instance found');
|
||||
}
|
||||
|
||||
// TODO: Implement routes
|
||||
// const app = this._app.value;
|
||||
const app = this._app.value;
|
||||
|
||||
let catchAllHandler: Nullable<RequestHandler> = null;
|
||||
|
||||
const registerRoutes = (routes: Record<string, RequestHandler>): void => {
|
||||
for (const route of Object.entries(routes)) {
|
||||
const [name, handler] = route;
|
||||
|
||||
// let catchAllHandler = null;
|
||||
console.log(`[${name}] registering [%o]`, handler);
|
||||
if (name === '*') {
|
||||
console.log('Catch-all handler', name, handler);
|
||||
if (catchAllHandler) {
|
||||
throw new Error(`Only one catch-all handler can ber registered per server, ignoring conflicting catch-all`);
|
||||
}
|
||||
|
||||
// for (const route of this._routes.getGETRoutes().entries()) {
|
||||
// const [handler, name] = route;
|
||||
catchAllHandler = handler;
|
||||
continue;
|
||||
}
|
||||
|
||||
// if (name === '*') {
|
||||
// if (catchAllHandler) {
|
||||
// throw new Error(`Only one catch-all handler can ber registered per server, ignoring conflicting catch-all`);
|
||||
// }
|
||||
this._logger.debug(`Registering [GET] route [${name}] with [%o]`, handler);
|
||||
app.get(name, handler);
|
||||
}
|
||||
}
|
||||
|
||||
// catchAllHandler = handler;
|
||||
const getRoutes = this._routes.getGETRoutes();
|
||||
const postRoutes = this._routes.getPOSTRoutes();
|
||||
const putRoutes = this._routes.getPUTRoutes();
|
||||
const patchRoutes = this._routes.getPATCHRoutes();
|
||||
const deleteRoutes = this._routes.getDELETERoutes();
|
||||
|
||||
// continue;
|
||||
// }
|
||||
|
||||
// this._logger.debug(`Registering [GET] route [${name}]`);
|
||||
// // app.get(name, this._json);
|
||||
// }
|
||||
registerRoutes(getRoutes);
|
||||
registerRoutes(postRoutes);
|
||||
registerRoutes(putRoutes);
|
||||
registerRoutes(patchRoutes);
|
||||
registerRoutes(deleteRoutes);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type {RequestHandler} from 'express';
|
||||
|
||||
export default interface IRoutes {
|
||||
getGETRoutes(): Record<string, () => void | Promise<void>>;
|
||||
getPOSTRoutes(): Record<string, () => void | Promise<void>>;
|
||||
getPUTRoutes(): Record<string, () => void | Promise<void>>;
|
||||
getPATCHRoutes(): Record<string, () => void | Promise<void>>;
|
||||
getDELETERoutes(): Record<string, () => void | Promise<void>>;
|
||||
getGETRoutes(): Record<string, RequestHandler>;
|
||||
getPOSTRoutes(): Record<string, RequestHandler>;
|
||||
getPUTRoutes(): Record<string, RequestHandler>;
|
||||
getPATCHRoutes(): Record<string, RequestHandler>;
|
||||
getDELETERoutes(): Record<string, RequestHandler>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import {LoggerFactory} from '@techniker-me/logger';
|
||||
import {Server as HttpServer} from 'node:http';
|
||||
import {IncomingMessage} from 'node:http';
|
||||
import ws, {WebSocketServer as WSServer, WebSocket} from 'ws';
|
||||
import Assert from '../../lang/Assert';
|
||||
import Strings from '../../lang/Strings';
|
||||
import WebsocketExtensions from 'websocket-extensions';
|
||||
import deflate from 'permessage-deflate';
|
||||
|
||||
interface ExtendedWebSocket extends WebSocket {
|
||||
id: string;
|
||||
remoteAddress: string;
|
||||
isOpen(): boolean;
|
||||
isClosed(): boolean;
|
||||
}
|
||||
|
||||
function deepClone<T>(obj: T): T {
|
||||
if (obj === null || typeof obj !== 'object') {
|
||||
return obj;
|
||||
}
|
||||
|
||||
if (obj instanceof Date) {
|
||||
return new Date(obj.getTime()) as T;
|
||||
}
|
||||
|
||||
if (obj instanceof Array) {
|
||||
return obj.map(item => deepClone(item)) as T;
|
||||
}
|
||||
|
||||
if (obj instanceof Object) {
|
||||
const copy = {} as T;
|
||||
Object.keys(obj).forEach(key => {
|
||||
copy[key as keyof T] = deepClone(obj[key as keyof T]);
|
||||
});
|
||||
return copy;
|
||||
}
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
function getRemoteAddress(connection: ExtendedWebSocket, req: IncomingMessage): string {
|
||||
// WebSocket doesn't expose socket directly, so we need to use the underlying socket
|
||||
const socket = (connection as WebSocket & {_socket?: {remoteAddress?: string}})._socket;
|
||||
const remoteAddress = socket?.remoteAddress || '::';
|
||||
const xForwardedFor = req.headers['x-forwarded-for'];
|
||||
|
||||
let forwardingRemoteAddressChain: string[] = [];
|
||||
if (typeof xForwardedFor === 'string') {
|
||||
forwardingRemoteAddressChain = xForwardedFor.split(', ').filter(Boolean);
|
||||
} else if (Array.isArray(xForwardedFor)) {
|
||||
forwardingRemoteAddressChain = xForwardedFor.flatMap(addr => addr.split(', ')).filter(Boolean);
|
||||
}
|
||||
|
||||
forwardingRemoteAddressChain.push(remoteAddress);
|
||||
|
||||
// For now, just return the first address (most direct)
|
||||
// TODO: Implement proper proxy trust checking
|
||||
return forwardingRemoteAddressChain[forwardingRemoteAddressChain.length - 1] || '::';
|
||||
}
|
||||
|
||||
export type WebSovketServerOptions = {
|
||||
path?: string;
|
||||
};
|
||||
|
||||
const connectionIdLength = 32;
|
||||
|
||||
export default class WebSocketServer {
|
||||
private readonly _logger = LoggerFactory.getLogger('WebSocketServer');
|
||||
private readonly _httpServer: HttpServer;
|
||||
private readonly _parameters: Record<string, string>;
|
||||
private _server?: WSServer;
|
||||
private _extensions?: WebsocketExtensions;
|
||||
|
||||
constructor(httpServer: HttpServer, parameters: WebSovketServerOptions) {
|
||||
this._httpServer = httpServer;
|
||||
this._parameters = parameters;
|
||||
}
|
||||
|
||||
public start(
|
||||
connectDelegate: (connection: ExtendedWebSocket, req: IncomingMessage) => void,
|
||||
requestDelegate: (connection: ExtendedWebSocket, message: Buffer) => void,
|
||||
disconnectDelegate: (connection: ExtendedWebSocket, reasonCode: number, description: string) => void,
|
||||
pongDelegate: (connection: ExtendedWebSocket, message: Buffer) => void
|
||||
): void {
|
||||
Assert.isFunction('connectDelegate', connectDelegate);
|
||||
Assert.isFunction('requestDelegate', requestDelegate);
|
||||
Assert.isFunction('disconnectDelegate', disconnectDelegate);
|
||||
Assert.isFunction('pongDelegate', pongDelegate);
|
||||
|
||||
const serverOptions = deepClone(this._parameters);
|
||||
|
||||
const address = this._httpServer.address();
|
||||
const port = typeof address === 'string' ? address : address?.port?.toString() || 'unknown';
|
||||
const path = serverOptions['path'] || '/';
|
||||
|
||||
this._logger.info(`Listening on port [${port}] and bound to [${path}]`);
|
||||
|
||||
(serverOptions as Record<string, unknown>)['noServer'] = true;
|
||||
|
||||
this._server = new WSServer(serverOptions);
|
||||
this._extensions = new WebsocketExtensions();
|
||||
|
||||
this._extensions.add(deflate());
|
||||
(this._server as WSServer & {_server?: HttpServer})._server = this._httpServer;
|
||||
this._httpServer.on('error', this._server.emit.bind(this._server, 'error'));
|
||||
this._httpServer.on('listening', this._server.emit.bind(this._server, 'listening'));
|
||||
this._httpServer.on('upgrade', (req, socket, head) => {
|
||||
if (!req.url?.startsWith(path)) {
|
||||
this._logger.debug(`Skipping upgrade of http request due to incorrect path [${req.url}]`);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this._server!.handleUpgrade(req, socket, head, (ws: WebSocket) => {
|
||||
this._server!.emit('connection', ws, req as IncomingMessage);
|
||||
});
|
||||
});
|
||||
this._httpServer.on('request', (req, res) => {
|
||||
this._logger.debug(`[HttpServer] Request: ${req.method} ${req.url} -> ${res.statusCode}`);
|
||||
});
|
||||
|
||||
this._server.on('error', err => this._logger.error('An error occurred with WebSocket', err));
|
||||
this._server.on('connection', (connection: WebSocket, req: IncomingMessage) => {
|
||||
let closed = false;
|
||||
|
||||
try {
|
||||
const extendedConnection = connection as ExtendedWebSocket;
|
||||
extendedConnection.id = Strings.randomString(connectionIdLength);
|
||||
extendedConnection.remoteAddress = getRemoteAddress(extendedConnection, req);
|
||||
extendedConnection.isOpen = () => connection.readyState === ws.OPEN;
|
||||
extendedConnection.isClosed = () => connection.readyState === ws.CLOSED;
|
||||
|
||||
connection.on('error', (e: Error) => {
|
||||
this._logger.error('An error occurred on websocket', e);
|
||||
});
|
||||
|
||||
connection.on('message', (message: Buffer) => {
|
||||
try {
|
||||
requestDelegate(extendedConnection, message);
|
||||
} catch (e) {
|
||||
this._logger.error('Request handler failed for message [%s]', message, e);
|
||||
}
|
||||
});
|
||||
|
||||
connection.on('close', (reasonCode: number, description: string) => {
|
||||
if (closed) {
|
||||
this._logger.warn('[%s] Multiple close events [%s] [%s] [%s]', extendedConnection.id, extendedConnection.remoteAddress, reasonCode, description);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
closed = true;
|
||||
|
||||
try {
|
||||
disconnectDelegate(extendedConnection, reasonCode, description);
|
||||
} catch (e) {
|
||||
this._logger.error('Disconnect handler failed', e);
|
||||
}
|
||||
});
|
||||
|
||||
connection.on('pong', (message: Buffer) => {
|
||||
try {
|
||||
pongDelegate(extendedConnection, message);
|
||||
} catch (e) {
|
||||
this._logger.error('Pong handler failed', e);
|
||||
}
|
||||
});
|
||||
|
||||
connectDelegate(extendedConnection, req);
|
||||
} catch (e) {
|
||||
this._logger.error('Accept/connect handler failed', e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public stop() {}
|
||||
}
|
||||
32
Web/WebSocket/websocket/server/src/types/modules.d.ts
vendored
Normal file
32
Web/WebSocket/websocket/server/src/types/modules.d.ts
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
declare module 'websocket-extensions' {
|
||||
interface Extension {
|
||||
name: string;
|
||||
params?: Record<string, string | number | boolean>;
|
||||
}
|
||||
|
||||
class WebSocketExtensions {
|
||||
constructor();
|
||||
add(extension: {name: string; [key: string]: unknown}): void;
|
||||
negotiate(protocol: string, extensions: string[]): string | false;
|
||||
parse(extensions: string): Extension[];
|
||||
generate(extensions: Extension[]): string;
|
||||
}
|
||||
|
||||
export = WebSocketExtensions;
|
||||
}
|
||||
|
||||
declare module 'permessage-deflate' {
|
||||
interface DeflateOptions {
|
||||
serverNoContextTakeover?: boolean;
|
||||
clientNoContextTakeover?: boolean;
|
||||
serverMaxWindowBits?: number;
|
||||
clientMaxWindowBits?: number;
|
||||
level?: number;
|
||||
memLevel?: number;
|
||||
strategy?: number;
|
||||
}
|
||||
|
||||
function deflate(options?: DeflateOptions): {name: string; [key: string]: unknown};
|
||||
|
||||
export = deflate;
|
||||
}
|
||||
Reference in New Issue
Block a user