Update WebSocket server with favicon assets and additional dependencies
* Added favicon assets including various sizes and a manifest file for improved branding * Updated package.json to include new type definitions and dependencies for body-parser, cors, lru-cache, moment, multer, on-headers, response-time, and serve-favicon * Enhanced HttpServer class to utilize the favicon and improved middleware configuration for handling requests
This commit is contained in:
6
Web/WebSocket/websocket/server/assets/favicon/about.txt
Normal file
6
Web/WebSocket/websocket/server/assets/favicon/about.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
This favicon was generated using the following graphics from Twitter Twemoji:
|
||||
|
||||
- Graphics Title: 1f916.svg
|
||||
- Graphics Author: Copyright 2020 Twitter, Inc and other contributors (https://github.com/twitter/twemoji)
|
||||
- Graphics Source: https://github.com/twitter/twemoji/blob/master/assets/svg/1f916.svg
|
||||
- Graphics License: CC-BY 4.0 (https://creativecommons.org/licenses/by/4.0/)
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
BIN
Web/WebSocket/websocket/server/assets/favicon/favicon-16x16.png
Normal file
BIN
Web/WebSocket/websocket/server/assets/favicon/favicon-16x16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 680 B |
BIN
Web/WebSocket/websocket/server/assets/favicon/favicon-32x32.png
Normal file
BIN
Web/WebSocket/websocket/server/assets/favicon/favicon-32x32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
Web/WebSocket/websocket/server/assets/favicon/favicon.ico
Normal file
BIN
Web/WebSocket/websocket/server/assets/favicon/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1 @@
|
||||
{"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"}
|
||||
@@ -20,8 +20,15 @@
|
||||
"@eslint/js": "9.36.0",
|
||||
"@eslint/json": "0.13.2",
|
||||
"@eslint/markdown": "7.3.0",
|
||||
"@types/body-parser": "1.19.6",
|
||||
"@types/cors": "2.8.19",
|
||||
"@types/lru-cache": "7.10.9",
|
||||
"@types/morgan": "1.9.10",
|
||||
"@types/multer": "2.0.0",
|
||||
"@types/node": "24.5.2",
|
||||
"@types/on-headers": "1.0.4",
|
||||
"@types/response-time": "2.3.9",
|
||||
"@types/serve-favicon": "2.5.7",
|
||||
"eslint": "9.36.0",
|
||||
"globals": "16.4.0",
|
||||
"jiti": "2.6.0",
|
||||
@@ -34,6 +41,14 @@
|
||||
"dependencies": {
|
||||
"@techniker-me/logger": "0.0.15",
|
||||
"@techniker-me/tools": "2025.0.16",
|
||||
"morgan": "1.10.1"
|
||||
"body-parser": "2.2.0",
|
||||
"cors": "2.8.5",
|
||||
"lru-cache": "11.2.2",
|
||||
"moment": "2.30.1",
|
||||
"morgan": "1.10.1",
|
||||
"multer": "2.0.2",
|
||||
"on-headers": "1.1.0",
|
||||
"response-time": "2.3.4",
|
||||
"serve-favicon": "2.5.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,28 @@
|
||||
import HttpServer from './net/http/HttpServer';
|
||||
import {LoggerFactory} from '@techniker-me/logger';
|
||||
|
||||
import {LoggerFactory} from '@techniker-me/logger';
|
||||
import HttpServer from './net/http/HttpServer';
|
||||
import path from 'node:path';
|
||||
const logger = LoggerFactory.getLogger('Server');
|
||||
|
||||
const httpServer = new HttpServer('http', 3000, {
|
||||
getGETRoutes: () => ({}),
|
||||
getPOSTRoutes: () => ({}),
|
||||
getPUTRoutes: () => ({}),
|
||||
getPATCHRoutes: () => ({}),
|
||||
getDELETERoutes: () => ({}),
|
||||
}, {}, '', [], '', {});
|
||||
const httpServer = new HttpServer(
|
||||
'http',
|
||||
3000,
|
||||
{
|
||||
getGETRoutes: () => ({}),
|
||||
getPOSTRoutes: () => ({}),
|
||||
getPUTRoutes: () => ({}),
|
||||
getPATCHRoutes: () => ({}),
|
||||
getDELETERoutes: () => ({})
|
||||
},
|
||||
{},
|
||||
'',
|
||||
[],
|
||||
path.resolve(process.cwd(), 'assets', 'favicon', 'favicon.ico'),
|
||||
{}
|
||||
);
|
||||
|
||||
httpServer.on('error', () => {
|
||||
logger.error('[HttpServer] Error');
|
||||
logger.error('[HttpServer] Error');
|
||||
});
|
||||
|
||||
httpServer.start();
|
||||
httpServer.start();
|
||||
|
||||
@@ -73,27 +73,40 @@ export default class Assert {
|
||||
}
|
||||
}
|
||||
|
||||
public static isFunction(name: string, value: unknown): asserts value is Function {
|
||||
public static isFunction(name: string, value: unknown): asserts value is (...args: unknown[]) => unknown {
|
||||
if (typeof value !== 'function') {
|
||||
throw new Error(`[${name}] must be a function, instead received [${typeof value}]`);
|
||||
}
|
||||
}
|
||||
|
||||
public static satisfiesInterface<T>(name: string, obj: unknown, requiredProps: (keyof T)[]): asserts obj is T {
|
||||
Assert.isObject(name, obj);
|
||||
|
||||
|
||||
for (const prop of requiredProps) {
|
||||
if (!(prop in (obj as any))) {
|
||||
if (!(prop in (obj as Record<string, unknown>))) {
|
||||
throw new Error(`[${name}] missing required property: ${String(prop)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static isArray<T>(name: string, value: unknown): asserts value is T[] {
|
||||
public static isArray(name: string, value: unknown): asserts value is unknown[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new Error(`[${name}] must be an array, instead received [${typeof value}]`);
|
||||
}
|
||||
}
|
||||
|
||||
public static isArrayOf<T>(name: string, arrayValueType: T, value: unknown): asserts value is T[] {
|
||||
Assert.isArray(name, value);
|
||||
|
||||
for (const item of value) {
|
||||
const itemTypeof = typeof item;
|
||||
|
||||
if (itemTypeof !== arrayValueType) {
|
||||
throw new Error(`[${name}] must be an array of [${arrayValueType}] received [${itemTypeof}]`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static isStringArray(name: string, value: unknown): asserts value is string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new Error(`[${name}] must be an array, instead received [${typeof value}]`);
|
||||
@@ -116,6 +129,7 @@ export default class Assert {
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line
|
||||
public static isInstance<T>(name: string, parentClass: new (...args: any[]) => T, object: unknown): asserts object is T {
|
||||
if (object === null || object === undefined || typeof object !== 'object') {
|
||||
throw new Error(`[${name}] must be an instance of [${parentClass.constructor.name}], instead received [${typeof object}]`);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Nullable } from '../../types/optional';
|
||||
import {Nullable} from '../../types/optional';
|
||||
import Assert from '../../lang/Assert';
|
||||
import moment from 'moment';
|
||||
import type {Server} from 'node:http';
|
||||
import http from 'node:http';
|
||||
import {EventEmitter} from 'node:events';
|
||||
@@ -9,30 +10,62 @@ import IRoutes from './IRoutes';
|
||||
import {Subject} from '@techniker-me/tools';
|
||||
import express from 'express';
|
||||
import morgan from 'morgan';
|
||||
import favicon from 'serve-favicon';
|
||||
import bodyParser from 'body-parser';
|
||||
import multer from 'multer';
|
||||
import {Kilobytes} from '../../types/Units';
|
||||
import responseTime from 'response-time';
|
||||
import onHeaders from 'on-headers';
|
||||
import {LRUCache} from 'lru-cache';
|
||||
|
||||
const requestSizeLimit: Kilobytes = 10240;
|
||||
const defaultTcpSocketTimeout = moment.duration(720, 'seconds'); // Google HTTPS load balancer expects at least 600 seconds
|
||||
const defaultKeepAliveTimeout = moment.duration(660, 'seconds'); // Google HTTPS load balancer expects at least 600 seconds
|
||||
// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Max-Age#:~:text=The%20Access%2DControl%2DMax%2D,Headers%20headers)%20can%20be%20cached.
|
||||
const corsAccessControlMaxAge = moment.duration(24, 'hours');
|
||||
const shortTermCaching = 'public, max-age=20, s-maxage=20';
|
||||
const tlsSessionTimeout = moment.duration(5, 'minutes');
|
||||
const maxCachedTlsSessions = 1000;
|
||||
|
||||
export default class HttpServer {
|
||||
private readonly _logger: ILogger = LoggerFactory.getLogger('HttpServer');
|
||||
private readonly _logger: ILogger = LoggerFactory.getLogger('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;
|
||||
// @ts-expect-error - unused parameter for future functionality
|
||||
private readonly _viewParameters: string;
|
||||
private readonly _resourcesPaths: string[];
|
||||
private readonly _favicon: string;
|
||||
private readonly _cors: object;
|
||||
private readonly _app: Subject<Nullable<express.Application>>;
|
||||
private readonly _eventEmitter: EventEmitter;
|
||||
private readonly _server: Subject<Nullable<Server>>;
|
||||
private readonly _tlsSessionCache = new LRUCache({
|
||||
ttl: tlsSessionTimeout.asMilliseconds(),
|
||||
max: maxCachedTlsSessions
|
||||
});
|
||||
|
||||
constructor(protocol: 'https' | 'http', port: number, routes: IRoutes, viewsPath: object, viewParameters: string, resourcesPaths: string[], favicon: string, cors: object) {
|
||||
constructor(
|
||||
protocol: 'https' | 'http',
|
||||
port: number,
|
||||
routes: IRoutes,
|
||||
viewsPath: object,
|
||||
viewParameters: string,
|
||||
resourcesPaths: string[],
|
||||
favicon: string,
|
||||
cors: object
|
||||
) {
|
||||
Assert.isString('protocol', protocol);
|
||||
Assert.isNumber('port', port);
|
||||
Assert.satisfiesInterface ('routes', routes, ['getGETRoutes', 'getPOSTRoutes', 'getPUTRoutes', 'getPATCHRoutes', 'getDELETERoutes']);
|
||||
Assert.isObject('viewsPath', viewsPath);
|
||||
Assert.satisfiesInterface('routes', routes, ['getGETRoutes', 'getPOSTRoutes', 'getPUTRoutes', 'getPATCHRoutes', 'getDELETERoutes']);
|
||||
// Assert.isObjectOf<string>('viewsPath', viewsPath);
|
||||
Assert.isString('viewParameters', viewParameters);
|
||||
Assert.isStringArray('resourcesPaths', resourcesPaths);
|
||||
Assert.isArrayOf<string>('resourcesPaths', 'string', resourcesPaths);
|
||||
Assert.isString('favicon', favicon);
|
||||
Assert.isObject('cors', cors);
|
||||
// Assert.isObjectOf<string>('cors', cors, 'string');
|
||||
|
||||
this._protocol = protocol;
|
||||
this._port = port;
|
||||
@@ -47,22 +80,281 @@ export default class HttpServer {
|
||||
this._server = new Subject<Nullable<Server>>(null);
|
||||
}
|
||||
|
||||
public async start() {
|
||||
const app = this._app.value = express();
|
||||
|
||||
app.use(morgan('common'));
|
||||
this._server.value = http.createServer(app);
|
||||
public start() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const app = (this._app.value = express());
|
||||
|
||||
this._server.value.listen(this._port, () => {
|
||||
this._logger.info(`Server is running on port ${this._port}`);
|
||||
this.configureListener();
|
||||
this.configureMiddleware();
|
||||
this.configureResources();
|
||||
this.configureRoutes();
|
||||
|
||||
app.set('x-powered-by', false);
|
||||
|
||||
const server = (this._server.value = http.createServer(app));
|
||||
|
||||
const onListen = () => {
|
||||
this._logger.info('HTTP Server listening on %s://*:%s', this._protocol, this._port);
|
||||
|
||||
server.removeListener('error', onError);
|
||||
|
||||
resolve(this);
|
||||
};
|
||||
|
||||
const onError = (err: unknown) => {
|
||||
server.removeListener('listening', onListen);
|
||||
|
||||
reject(err);
|
||||
};
|
||||
|
||||
server.keepAliveTimeout = defaultKeepAliveTimeout.milliseconds();
|
||||
server.timeout = defaultTcpSocketTimeout.asMilliseconds();
|
||||
server.setTimeout(defaultTcpSocketTimeout.asMilliseconds());
|
||||
server.once('error', onError);
|
||||
server.once('listening', onListen);
|
||||
|
||||
server.on('newSession', (sessionId, sessionData, callback) => {
|
||||
const cacheId = sessionId.toString('hex');
|
||||
|
||||
this._tlsSessionCache.set(cacheId, sessionData);
|
||||
this._logger.debug('Created new TLS session [%s]', cacheId);
|
||||
|
||||
callback();
|
||||
});
|
||||
|
||||
server.on('resumeSession', (sessionId, callback) => {
|
||||
const cacheId = sessionId.toString('hex');
|
||||
const sessionData = this._tlsSessionCache.get(cacheId);
|
||||
|
||||
callback(null, sessionData);
|
||||
|
||||
if (sessionData) {
|
||||
this._logger.debug('Resumed TLS session [%s]', cacheId);
|
||||
} else {
|
||||
this._logger.debug('TLS session [%s] not found', cacheId);
|
||||
}
|
||||
});
|
||||
|
||||
server.listen({
|
||||
port: this._port,
|
||||
backlog: 16 * 1024
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
public on(event: string, handler: () => void): void {
|
||||
Assert.isNonEmptyString('event', event);
|
||||
Assert.isFunction('handler', handler);
|
||||
|
||||
this._eventEmitter.on(event, handler);
|
||||
}
|
||||
|
||||
private configureListener() {
|
||||
if (!this._app.value) {
|
||||
throw new Error('Unable to configure listener, no app instance found');
|
||||
}
|
||||
|
||||
const app = this._app.value;
|
||||
|
||||
app.use((req, res, next) => {
|
||||
req.on('finish', () => {
|
||||
this._eventEmitter.emit('request', req.method, req.url, res.statusCode, req.headers);
|
||||
});
|
||||
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
private configureMiddleware() {
|
||||
if (!this._app.value) {
|
||||
throw new Error('Unable to configure middleware, no app instance found');
|
||||
}
|
||||
|
||||
const app = this._app.value;
|
||||
const logger = this._logger;
|
||||
|
||||
app.use(morgan('common'));
|
||||
app.enable('trust proxy');
|
||||
app.set('env', 'development'); // setting env to test prevents logging to the console
|
||||
app.use(favicon(this._favicon));
|
||||
app.use(
|
||||
morgan(
|
||||
':remote-addr - :remote-user [:date[clf]] ":method :url HTTP/:http-version" :status :res[content-length] ":referrer" ":user-agent" :response-time',
|
||||
{
|
||||
stream: {
|
||||
write(line) {
|
||||
logger.info(line.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
app.use(
|
||||
bodyParser.text({
|
||||
type: 'application/sdp',
|
||||
limit: requestSizeLimit
|
||||
})
|
||||
);
|
||||
app.use(
|
||||
bodyParser.text({
|
||||
type: 'application/trickle-ice-sdpfrag',
|
||||
limit: requestSizeLimit
|
||||
})
|
||||
);
|
||||
app.use(
|
||||
bodyParser.urlencoded({
|
||||
extended: true,
|
||||
limit: requestSizeLimit
|
||||
})
|
||||
);
|
||||
app.use(
|
||||
multer({
|
||||
limits: {
|
||||
fields: 1,
|
||||
fieldNameSize: 100,
|
||||
fieldSize: requestSizeLimit,
|
||||
files: 0,
|
||||
parts: 1,
|
||||
headerPairs: 1
|
||||
}
|
||||
}).none()
|
||||
);
|
||||
app.use((req, _res, next) => {
|
||||
const contentType = req?.headers?.['content-type'] || '';
|
||||
|
||||
if (contentType.startsWith('multipart/form-data;')) {
|
||||
if (req?.body?.jsonBody) {
|
||||
req.body = JSON.parse(req.body.jsonBody);
|
||||
}
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
app.use(responseTime());
|
||||
app.use((req, res, next) => {
|
||||
res.set('x-origination', 'Platform');
|
||||
|
||||
if (req.secure) {
|
||||
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload');
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
if (this._cors) {
|
||||
this._logger.info('Enable CORS on %s://*:%s', this._protocol, this._port);
|
||||
|
||||
const cachedCorsAllowOrigins: Record<string, string> = {};
|
||||
const getCorsAllowOrigin = (url: string) => {
|
||||
let corsAllowOrigin = cachedCorsAllowOrigins[url];
|
||||
|
||||
if (!Object.hasOwn(cachedCorsAllowOrigins, url)) {
|
||||
Object.entries(this._cors).forEach(([key, value]) => {
|
||||
if (url.startsWith(key)) {
|
||||
corsAllowOrigin = value;
|
||||
}
|
||||
});
|
||||
|
||||
cachedCorsAllowOrigins[url] = corsAllowOrigin ?? '';
|
||||
}
|
||||
|
||||
return corsAllowOrigin;
|
||||
};
|
||||
|
||||
app.use((req, res, next) => {
|
||||
const corsAllowOrigin = getCorsAllowOrigin(req.url);
|
||||
|
||||
if (corsAllowOrigin) {
|
||||
res.header('Access-Control-Allow-Origin', corsAllowOrigin);
|
||||
res.header(
|
||||
'Access-Control-Allow-Headers',
|
||||
'Authorization, Origin, Range, X-Requested-With, If-Modified-Since, Accept, Keep-Alive, Cache-Control, Content-Type, DNT'
|
||||
);
|
||||
res.header('Access-Control-Allow-Methods', 'POST, GET, HEAD, OPTIONS, PUT, PATCH, DELETE');
|
||||
res.header('Access-Control-Expose-Headers', 'Server, Range, Date, Content-Disposition, X-Timer, ETag, Link, Location');
|
||||
res.header('Access-Control-Max-Age', corsAccessControlMaxAge.asSeconds().toString());
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.header('Cache-Control', shortTermCaching);
|
||||
}
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
}
|
||||
app.use((_req, res, next) => {
|
||||
const startTimeSeconds = Date.now() / 1000;
|
||||
const startTimeNanoseconds = process.hrtime.bigint();
|
||||
|
||||
onHeaders(res, () => {
|
||||
const durationNanoseconds = process.hrtime.bigint() - startTimeNanoseconds;
|
||||
const durationMilliseconds = durationNanoseconds / 1000000n;
|
||||
|
||||
// https://developer.fastly.com/reference/http/http-headers/X-Timer/
|
||||
// S{unixStartTimeSeconds},VS0,VE{durationMilliseconds}
|
||||
res.setHeader('X-Timer', `S${startTimeSeconds},VS0,VE${durationMilliseconds}`);
|
||||
});
|
||||
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
private configureResources() {
|
||||
if (!this._app.value) {
|
||||
throw new Error('Unable to configure resources, no app instance found');
|
||||
}
|
||||
|
||||
const app = this._app.value;
|
||||
|
||||
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
|
||||
private errorHandler(err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) {
|
||||
this._logger.error(err.message);
|
||||
|
||||
res.status(500).send({status: 'error'});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
private genericNotFoundHandler(_req: express.Request, res: express.Response) {
|
||||
res.status(404).send({status: 'not-found'});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
private configureRoutes() {
|
||||
if (!this._app.value) {
|
||||
throw new Error('Unable to configure routes, no app instance found');
|
||||
}
|
||||
|
||||
// TODO: Implement routes
|
||||
// const app = this._app.value;
|
||||
|
||||
// let catchAllHandler = null;
|
||||
|
||||
// for (const route of this._routes.getGETRoutes().entries()) {
|
||||
// const [handler, name] = route;
|
||||
|
||||
// if (name === '*') {
|
||||
// if (catchAllHandler) {
|
||||
// throw new Error(`Only one catch-all handler can ber registered per server, ignoring conflicting catch-all`);
|
||||
// }
|
||||
|
||||
// catchAllHandler = handler;
|
||||
|
||||
// continue;
|
||||
// }
|
||||
|
||||
// this._logger.debug(`Registering [GET] route [${name}]`);
|
||||
// // app.get(name, this._json);
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
|
||||
export default interface IRoutes {
|
||||
getGETRoutes(): Record<string, () => (Promise<void> | void);
|
||||
getPOSTRoutes(): Record<string, () => (Promise<void> | void);
|
||||
getPUTRoutes(): Record<string, () => (Promise<void> | void);
|
||||
getPATCHRoutes(): Record<string, () => (Promise<void> | void);
|
||||
getDELETERoutes(): Record<string, () => (Promise<void> | void);
|
||||
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>>;
|
||||
}
|
||||
|
||||
18
Web/WebSocket/websocket/server/src/types/Units.ts
Normal file
18
Web/WebSocket/websocket/server/src/types/Units.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export type Bytes = number;
|
||||
export type Kilobytes = number;
|
||||
export type Megabytes = number;
|
||||
export type Gigabytes = number;
|
||||
export type Terabytes = number;
|
||||
export type Petabytes = number;
|
||||
export type Exabytes = number;
|
||||
export type Zettabytes = number;
|
||||
export type Yottabytes = number;
|
||||
|
||||
export type Milliseconds = number;
|
||||
export type Seconds = number;
|
||||
export type Minutes = number;
|
||||
export type Hours = number;
|
||||
export type Days = number;
|
||||
export type Weeks = number;
|
||||
export type Months = number;
|
||||
export type Years = number;
|
||||
@@ -1,2 +1,2 @@
|
||||
export type Optional<T> = T | undefined | null;
|
||||
export type Nullable<T> = T | null;
|
||||
export type Nullable<T> = T | null;
|
||||
|
||||
Reference in New Issue
Block a user