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