feat: initial release
Signed-off-by: Florian BOUILLON <f.bouillon@aptatio.com>
This commit is contained in:
commit
35315c5eb9
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
dist/
|
||||||
|
node_modules/
|
109
README.md
Normal file
109
README.md
Normal 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
2768
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
package.json
Normal file
24
package.json
Normal 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
184
src/Cache.ts
Normal 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
101
src/index.ts
Normal 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
9
tsconfig.json
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "./node_modules/@dzeio/config/tsconfig.base.json",
|
||||||
|
"files": [
|
||||||
|
"src/index.ts"
|
||||||
|
],
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist"
|
||||||
|
}
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user