Skip to content

Commit

Permalink
feat: memory caching (#15)
Browse files Browse the repository at this point in the history
* feat: memory caching

* Stable sort object keys

* Simplify

* typo

* Code review
typeofweb authored Jun 24, 2021
1 parent d70e621 commit 232d7fa
Showing 17 changed files with 487 additions and 50 deletions.
237 changes: 237 additions & 0 deletions __tests__/plugins.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
import { performance } from 'perf_hooks';

import { jest } from '@jest/globals';
import { number } from '@typeofweb/schema';

import { createApp, createPlugin } from '../src';
import { ms } from '../src/utils/ms';
import { wait } from '../src/utils/utils';

declare module '../src' {
interface TypeOfWebServerMeta {
readonly myPlugin: {
readonly someValue: number;
getUserById(id: string): Promise<readonly number[]>;
};
}
}

describe('plugins', () => {
it('should return function when cache is used', async () => {
const plugin = createPlugin('myPlugin', (_app) => {
return {
server(_server) {
return {
someValue: 42,
getUserById: {
cache: {
expireIn: ms('1 ms'),
},
fn: (id) => Promise.resolve(id.split('').map(Number)),
},
};
},
};
});
const app = createApp({}).route({
path: '/cache',
method: 'get',
validation: {},
handler: (request, _t) => {
return request.server.plugins.myPlugin.getUserById('123');
},
});
await app.plugin(plugin);

const result = await app.inject({
method: 'get',
path: '/cache',
});
expect(result.body).toEqual([1, 2, 3]);
});

it('should call the function only once when in cache', async () => {
const fn = jest.fn((id: string) => Promise.resolve(id.split('').map(Number)));

const plugin = createPlugin('myPlugin', (_app) => {
return {
server(_server) {
return {
someValue: 42,
getUserById: {
cache: {
expireAt: '00:00',
},
fn,
},
};
},
};
});

const app = createApp({}).route({
path: '/cache',
method: 'get',
validation: {},
handler: (request, _t) => {
return request.server.plugins.myPlugin.getUserById('123');
},
});
await app.plugin(plugin);

await app.inject({
method: 'get',
path: '/cache',
});
const result = await app.inject({
method: 'get',
path: '/cache',
});
expect(result.body).toEqual([1, 2, 3]);
expect(fn).toHaveBeenCalledTimes(1);
});

it('should call the function multiple times when cache expires', async () => {
const fn = jest.fn((id: string) => Promise.resolve(id.split('').map(Number)));

const plugin = createPlugin('myPlugin', (_app) => {
return {
server(_server) {
return {
someValue: 42,
getUserById: {
cache: {
expireIn: ms('10 ms'),
},
fn,
},
};
},
};
});

const app = createApp({}).route({
path: '/cache',
method: 'get',
validation: {},
handler: (request, _t) => {
return request.server.plugins.myPlugin.getUserById('123');
},
});
await app.plugin(plugin);

await app.inject({
method: 'get',
path: '/cache',
});
await wait(100);
await app.inject({
method: 'get',
path: '/cache',
});
expect(fn).toHaveBeenCalledTimes(2);
});

it('should differentiate functions by parameters', async () => {
const fn = jest.fn((id: string) => Promise.resolve(id.split('').map(Number)));

const plugin = createPlugin('myPlugin', (_app) => {
return {
server(_server) {
return {
someValue: 42,
getUserById: {
cache: {
expireIn: ms('1 second'),
},
fn,
},
};
},
};
});

const app = createApp({}).route({
path: '/cache/:seed',
method: 'get',
validation: {
params: {
seed: number(),
},
},
handler: (request, _t) => {
return request.server.plugins.myPlugin.getUserById(request.params.seed.toString());
},
});
await app.plugin(plugin);

const result1 = await app.inject({
method: 'get',
path: '/cache/123',
});
expect(result1.body).toEqual([1, 2, 3]);
expect(fn).toHaveBeenCalledTimes(1);

const result2 = await app.inject({
method: 'get',
path: '/cache/444',
});
expect(result2.body).toEqual([4, 4, 4]);
expect(fn).toHaveBeenCalledTimes(2);

const result3 = await app.inject({
method: 'get',
path: '/cache/123',
});
expect(result3.body).toEqual([1, 2, 3]);
expect(fn).toHaveBeenCalledTimes(2);
});

it('should call the function only once even when multiple requests are in parallel', async () => {
const FUNCTION_STALLING = ms('1 second');

const fn = jest.fn(async (id: string) => {
await wait(FUNCTION_STALLING);
return id.split('').map(Number);
});

const plugin = createPlugin('myPlugin', (_app) => {
return {
server(_server) {
return {
someValue: 42,
getUserById: {
cache: {
expireIn: ms('1 minute'),
},
fn,
},
};
},
};
});

const app = createApp({}).route({
path: '/cache',
method: 'get',
validation: {},
handler: (request, _t) => {
return request.server.plugins.myPlugin.getUserById('123');
},
});
await app.plugin(plugin);

const before = performance.now();
await Promise.all(
Array.from({ length: 100 }).map(() =>
app.inject({
method: 'get',
path: '/cache',
}),
),
);
const after = performance.now();
expect(after - before).toBeLessThan(2 * FUNCTION_STALLING);
expect(fn).toHaveBeenCalledTimes(1);
});
});
16 changes: 16 additions & 0 deletions __tests__/serializeObject.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { stableJsonStringify } from '../src/utils/serializeObject';

describe('stableJsonStringify', () => {
it('should stable sort regardless of properties order', () => {
expect(stableJsonStringify({ a: 123, b: 444 })).toEqual(stableJsonStringify({ b: 444, a: 123 }));
});

it('should stable stringify when mutating objects', () => {
const obj: Record<string, any> = { a: 123, c: 444, d: 0 };
obj.b = 333;
obj.e = 222;
delete obj.c;

expect(stableJsonStringify(obj)).toEqual(stableJsonStringify({ a: 123, b: 333, e: 222, d: 0 }));
});
});
6 changes: 3 additions & 3 deletions examples/simple.ts
Original file line number Diff line number Diff line change
@@ -3,7 +3,7 @@ import { number } from '@typeofweb/schema';
import { createApp, createPlugin } from '../dist/index';

declare function findOne(): unknown;
declare function findMany(): unknown;
declare function findMany(): 123;
declare module '../dist/index' {
interface TypeOfWebServerMeta {
readonly db: {
@@ -21,7 +21,7 @@ declare module '../dist/index' {
export const dbPlugin = createPlugin('db', () => {
return {
server() {
return { findOne, findMany };
return { findOne, findMany: { cache: {}, fn: findMany } };
},
};
});
@@ -49,7 +49,7 @@ void app.route({
},
handler(_request) {
// const { query, params } = request;
// request.server.plugins.db.findMany();
_request.server.plugins.db.findMany();
// request.server.events.emit('health-check', 123);
return 1;
},
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -35,10 +35,12 @@
],
"dependencies": {
"@typeofweb/schema": "0.8.0-5",
"@types/cache-manager": "3.4.0",
"@types/cookie-parser": "1.4.2",
"@types/cors": "2.8.10",
"@types/express": "4.17.12",
"@types/supertest": "2.0.11",
"cache-manager": "3.4.4",
"cookie-parser": "1.4.5",
"cors": "2.8.5",
"express": "4.17.1",
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -2,6 +2,7 @@ export { HttpError, isStatusError } from './utils/errors';
export { createApp } from './modules/app';
export { HttpStatusCode } from './modules/httpStatusCodes';
export { createPlugin } from './modules/plugins';
export { createCachedFunction } from './modules/cache';

export type { HttpMethod } from './modules/httpStatusCodes';
export type { TypeOfWebApp, AppOptions, TypeOfWebServer } from './modules/shared';
40 changes: 31 additions & 9 deletions src/modules/app.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { URL } from 'url';

import CacheManager from 'cache-manager';
import CookieParser from 'cookie-parser';
import Cors from 'cors';
import Supertest from 'supertest';
@@ -8,14 +9,15 @@ import { deepMerge } from '../utils/merge';
import { promiseOriginFnToNodeCallback } from '../utils/node';
import { generateServerId } from '../utils/uniqueId';

import { createCachedFunction } from './cache';
import { createEventBus } from './events';
import { initApp, listenExpressServer } from './http';
import { initRouter, validateRoute } from './router';

import type { DeepPartial, DeepWritable } from '../utils/types';
import type { AnyFunction, DeepPartial, DeepWritable, JsonPrimitive, MaybeAsync } from '../utils/types';
import type { TypeOfWebServerMeta } from './augment';
import type { TypeOfWebPluginInternal } from './plugins';
import type { AppOptions, TypeOfWebApp, TypeOfWebServer } from './shared';
import type { AppOptions, TypeOfWebRoute, TypeOfWebApp, TypeOfWebServer, TypeOfWebCacheConfig } from './shared';

const defaultAppOptions: AppOptions = {
hostname: 'localhost',
@@ -38,6 +40,7 @@ const defaultAppOptions: AppOptions = {

export function createApp(opts: DeepPartial<AppOptions>): TypeOfWebApp {
const options = deepMerge(opts, defaultAppOptions);
const memoryCache = CacheManager.caching({ store: 'memory', ttl: 0 });

const server: DeepWritable<TypeOfWebServer> = {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- these properties are supposed to be added by the plugins inside `async start()`
@@ -51,7 +54,7 @@ export function createApp(opts: DeepPartial<AppOptions>): TypeOfWebApp {
};

/* eslint-disable functional/prefer-readonly-type -- ok */
const routes: Array<Parameters<TypeOfWebApp['route']>[0]> = [];
const routes: Array<TypeOfWebRoute> = [];
/* eslint-disable functional/prefer-readonly-type -- ok */
const plugins: Array<TypeOfWebPluginInternal<string>> = [];

@@ -64,14 +67,33 @@ export function createApp(opts: DeepPartial<AppOptions>): TypeOfWebApp {
return;
}

// @ts-expect-error
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- serverMetadata will have valid type
const serverMetadata = (await plugin.value.server(
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- make server readonly
server as TypeOfWebServer,
)) as unknown as TypeOfWebServerMeta[keyof TypeOfWebServerMeta];
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- ok
const pluginServer = plugin.value.server as unknown as (server: TypeOfWebServer) => MaybeAsync<
Record<
string,
| JsonPrimitive
| AnyFunction
| {
readonly cache: TypeOfWebCacheConfig;
readonly fn: AnyFunction;
}
>
>;

const result = await pluginServer(server);
const serverMetadata = result
? Object.fromEntries(
Object.entries(result).map(([key, val]) => {
if (typeof val === 'object' && val && 'cache' in val) {
return [key, createCachedFunction({ ...val, cacheInstance: memoryCache })];
}
return [key, val];
}),
)
: null;

if (serverMetadata) {
// @ts-expect-error
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- if serverMetadata exists then plugin name is keyof TypeOfWebServerMeta
server.plugins[plugin.name as keyof TypeOfWebServerMeta] = serverMetadata;
}
Loading

0 comments on commit 232d7fa

Please sign in to comment.