feat: initial release

Signed-off-by: Florian BOUILLON <f.bouillon@aptatio.com>
This commit is contained in:
Florian Bouillon 2023-07-13 15:40:00 +02:00
commit 35315c5eb9
7 changed files with 3197 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
dist/
node_modules/

109
README.md Normal file
View File

@ -0,0 +1,109 @@
# @dzeio/cache
Simple Cache quick key/value server to store cache
## Usage
The server is available as a Websocket Server and an HTTP server
**Options**
Some options are settable and **optionnal** for manipulate how the key works
```jsonc
{
"options": {
"invalidationTags": ["array", "of", "tags", "to", "invalide", "a", "key"],
"ttl": 3600, // time to live of the key in seconds
"deletable": false // make the element not deletable by the remote (ttl still apply)
}
}
```
### Websocket
Connect to the websocket server at `/ws`
#### Set an item
```jsonc
{
"type": "set",
"key": "key of the item",
"scope": "optionnal scope",
"value": [{"the": "value", "in": {"any": "format"}, "you": "want"}, 1, true]
// other formats
// "value": true
// "value": 1
// "value": "pouet"
// addionnal storage options (see below)
// "options":
}
```
#### Get an item
```jsonc
{
"type": "get",
"key": "key of the item",
"scope": "optionnal scope"
}
```
The response will be formatted like this
```json
{
"value": "value" // the value
}
```
### HTTP Requests
#### Set an item
send a request to `/:item` or `/:scope/:item` with the method set to `PUT` with the following body:
```jsonc
{
"value": [{"the": "value", "in": {"any": "format"}, "you": "want"}, 1, true]
// other formats
// "value": true
// "value": 1
// "value": "pouet"
// addionnal storage options (see below)
// "options":
}
```
#### Get an item
send a request to `/:item` or `/:scope/:item` with the method set to `GET`.
The response will be formatted like this
```json
{
"value": "value" // the value
}
```
#### delete an item
send a request to `/:item` or `/:scope/:item` with the method set to `DELETE`.
The response will be formatted like this
```json
{
"existed": true, // return if the element existed
"deleted": true // return if the element is deleted (or did not exists in the first place)
}
```
/cache/:item
/cache/:scope/:item
/config/ttl
/actions/invalidate?scope=:scope

2768
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

24
package.json Normal file
View File

@ -0,0 +1,24 @@
{
"main": "dist/index.js",
"dependencies": {
"@dzeio/config": "^1.1.12",
"@dzeio/object-util": "^1.6.0",
"@types/express": "^4.17.17",
"@types/ws": "^8.5.5",
"express": "^4.18.2",
"typescript": "^5.1.6",
"ws": "^8.13.0"
},
"devDependencies": {
"@size-limit/preset-small-lib": "^8.2.6",
"ts-node": "^10.9.1",
"ts-node-dev": "^2.0.0"
},
"scripts": {
"dev": "ts-node-dev src/index.ts",
"build": "tsc"
},
"engines": {
"node": "^16 || ^18 || >=20"
}
}

184
src/Cache.ts Normal file
View File

@ -0,0 +1,184 @@
import { objectKeys, objectLoop } from '@dzeio/object-util'
interface CacheItem<T = any> {
value: T
tags?: Array<string>
ttl?: number
created?: number
deletable?: boolean
}
export type CacheItemOptions<T = any> = Omit<CacheItem<T>, 'created' | 'value'>
export default class Cache<T = any> {
public config: {
ttl: number | null
} = {
ttl: null
}
private global: Record<string, CacheItem<T>> = {}
private scoped: Record<string, CacheItem<T>> = {}
/**
* get the cache
* @param scope get the scoped elements
* @returns {Record<string, CacheItem<T>>}
*/
public getAll(scope: boolean = false) {
if (scope) {
return this.scoped
} else {
return this.global
}
}
public set(key: string, value: T, scope?: string, options?: CacheItemOptions<T>): boolean {
if (!key) {
return false
}
const obj: CacheItem<T> = {
...options,
value
}
const ttl = (options?.ttl ?? this.config.ttl)
if (typeof ttl === 'number') {
obj.created = new Date().getTime() / 1000
obj.ttl = ttl
}
if (scope) {
this.scoped[`${scope}-${key}`] = obj
return true
}
this.global[key] = obj
return true
}
public update(key: string, scope?: string, patch: any = null): {
updated: boolean
exists: boolean
value?: T
} {
const it = this.getItem(key, scope)
if (!it) {
return {
updated: false,
exists: false
}
}
let value = it.value
if (typeof value === 'number' && typeof patch === 'number') {
(value as number) += patch
} else if (typeof value === 'string') {
value = patch
} else if (typeof value === 'object') {
value = {...value, ...patch}
}
it.value = value
return {
updated: true,
exists: true,
value: it.value
}
}
public get(key: string, scope?: string): T | null {
const obj: CacheItem<T> | undefined = this.getItem(key, scope)
if (!obj) {
return null
}
if (typeof obj.ttl === 'number') {
const now = new Date().getTime() / 1000
if (obj.ttl + (obj.created as number) < now) {
this.delete(key, scope, true)
return null
}
}
return obj.value
}
public delete(key: string, scope?: string, force = false): {
existed: boolean
deleted: boolean
} {
const realKey = scope ? `${scope}-${key}` : key
const store = scope ? this.scoped : this.global
const exists = realKey in store
if (!exists) {
return {
existed: false,
deleted: true
}
}
const it = store[realKey]
if (!force && 'deletable' in it && !it.deletable) {
return {
existed: exists,
deleted: false
}
}
delete store[realKey]
return {
existed: exists,
deleted: true
}
}
public invalidate(tags: Array<string>): {
global: Array<string>
scoped: Array<[string, string]>
} {
const items: {
global: Array<string>
scoped: Array<[string, string]>
} = {
global: [],
scoped: []
}
objectLoop(this.global, (value, key) => {
if (!value.tags) {
return
}
if (this.findMutuals(value.tags, tags)) {
const res = this.delete(key)
if (res.deleted) [
items.global.push(key)
]
}
})
objectLoop(this.scoped, (value, fullKey) => {
if (!value.tags) {
return
}
if (this.findMutuals(value.tags, tags)) {
const [scope, key] = fullKey.split('-', 1) as [string, string]
const res = this.delete(key, scope)
if (res.deleted) [
items.scoped.push([scope, key])
]
}
})
return items
}
private findMutuals(arr1: Array<string>, arr2: Array<string>): Array<string> {
return arr1.filter((it) => arr2.includes(it))
}
public length(scope?: true | string) {
if (scope === true) {
return objectKeys(this.global).length + objectKeys(this.scoped).length
} else if (scope) {
return objectKeys(this.scoped)
}
return objectKeys(this.global)
}
private getItem(key: string, scope?: string): CacheItem<T> | undefined {
return scope ? this.scoped[`${scope}-${key}`] : this.global[key]
}
}

101
src/index.ts Normal file
View File

@ -0,0 +1,101 @@
import express, { Request, Response, Router } from 'express'
import http from 'http'
import { Server } from 'ws'
import Cache, { CacheItemOptions } from './Cache'
const cache = new Cache()
const server = express()
const httpServer = http.createServer(server)
const wss = new Server({server: httpServer, path: '/ws'})
wss.on('connection', (ws) => {
ws.on('message', (msg) => {
let json: {type: 'get' | 'set'} & any
try {
json = JSON.parse(msg.toString())
} catch {
ws.send(JSON.stringify({error: 'invalid json received'}))
return
}
if (json.type === 'get') {
ws.send(JSON.stringify({
value: cache.get(json.key, json.scope)
}))
return
} else if (json.type === 'set') {
const ok = cache.set(json.key, json.value, json.scope, json.options)
ws.send(JSON.stringify({
ok: ok
}))
} else {
ws.send(JSON.stringify({
error: 'json.type is not correctly set'
}))
}
})
})
function set(req: Request, res: Response, key: string, scope?: string) {
const body = req.body as {value: any, options?: CacheItemOptions}
const ok = cache.set(key, body.value, scope, body.options)
res.status(200).json({ ok })
}
function get(res: Response, key: string, scope?: string) {
const item = cache.get(key, scope)
if (!item) {
res.status(404).end()
return
}
/* It does Http as well */
res.status(200).json({
value: item
})
}
function del(res: Response, key: string, scope?: string) {
const item = cache.delete(key, scope)
/* It does Http as well */
res.status(item.deleted ? 200 : 400).json(item)
}
server
.use(express.json())
.post('/invalidate', (req, res) => {
const tags = req.body.tags
if (!Array.isArray(tags)) {
res.status(400).json({error: true, message: 'tags MUST be an array'})
return
}
res.json(cache.invalidate(tags))
})
.use(
'/config',
Router()
.get('/', (_, res) => {
res.json(cache.config)
})
.patch('/', (req, res) => {
Object.assign(cache.config, req.body)
res.json({ ok: true })
})
)
.use(
'/cache',
Router()
.put('/:key', (req, res) => set(req, res, req.params.key))
.get('/:key', (req, res) => get(res, req.params.key))
.delete('/:key', (req, res) => del(res, req.params.key))
.patch('/:key', (req, res) => {
res.json(cache.update(req.params.key, undefined, req.body.value))
})
.put('/:scope/:key', (req, res) => set(req, res, req.params.key, req.params.scope))
.patch('/:scope/:key', (req, res) => {
res.json(cache.update(req.params.key, req.params.scope, req.body.value))
})
.get('/:scope/:key', (req, res) => get(res, req.params.key, req.params.scope))
.delete('/:scope/:key', (req, res) => del(res, req.params.key, req.params.scope))
)
httpServer.listen(3000, () => {
console.log(`Listening to port ${3000}`);
})

9
tsconfig.json Normal file
View File

@ -0,0 +1,9 @@
{
"extends": "./node_modules/@dzeio/config/tsconfig.base.json",
"files": [
"src/index.ts"
],
"compilerOptions": {
"outDir": "dist"
}
}