initial commit

This commit is contained in:
Pooya Parsa 2021-03-08 00:18:27 +01:00
commit fa687aa697
21 changed files with 6760 additions and 0 deletions

15
.editorconfig Normal file
View File

@ -0,0 +1,15 @@
root = true
[*]
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
charset = utf-8
[*.js]
indent_style = space
indent_size = 2
[{package.json,*.yml,*.cjson}]
indent_style = space
indent_size = 2

5
.eslintrc Normal file
View File

@ -0,0 +1,5 @@
{
"extends": [
"@nuxtjs/eslint-config-typescript"
]
}

54
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,54 @@
name: ci
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
ci:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest]
node: [14]
steps:
- uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node }}
- name: checkout
uses: actions/checkout@master
- name: Get yarn cache directory path
id: yarn-cache-dir-path
run: echo "::set-output name=dir::$(yarn cache dir)"
- uses: actions/cache@v2
id: yarn-cache
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- name: Install dependencies
if: steps.cache.outputs.cache-hit != 'true'
run: yarn --frozen-lockfile --non-interactive
- name: Lint
run: yarn lint
- name: Build
run: yarn build
- name: Test
run: yarn jest
- name: Coverage
uses: codecov/codecov-action@v1

7
.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
.vscode
node_modules
*.log
.DS_Store
coverage
dist
tmp

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021 Pooya Parsa <pyapar@gmail.com>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

168
README.md Normal file
View File

@ -0,0 +1,168 @@
# unstorage
[![npm version][npm-version-src]][npm-version-href]
[![npm downloads][npm-downloads-src]][npm-downloads-href]
[![Github Actions][github-actions-src]][github-actions-href]
[![Codecov][codecov-src]][codecov-href]
[![bundle][bundle-src]][bundle-href]
> Universal key-value Storage
- Works in all environments (Browser, NodeJS and Workers)
- Asynchronous API
- Unix-style mountable paths (multi provider)
- Default in-memory storage
- Tree-shakable and lightweight core
WIP:
- JSON serialization and native types (destr)
- State compression
- State hydration
- Links (soft/junction)
- Binary data
- `getKeys` and `clear` with sub-path
- IPC/HTTP interface
- Virtual fs (fs-like interface)
- Watcher
- Reactivity
- Basic array operations
## Providers
- [x] Memory (Universal)
- [x] Filesystem (NodeJS)
- [x] Nested
- [ ] Flat
- [x] LocalStorage (Browser)
- [ ] Cookies (Browser)
- [ ] Location P
- [ ] HTTP (Universal)
- [ ] S3
- [ ] Cloudflare KV
- [ ] Github
## Usage
Install `unistorage` npm package:
```sh
yarn add unistorage
# or
npm i unistorage
```
```js
import { createStorage } from 'unistorage'
// Create a storage container with default memory storage
const storage = createStorage()
await storage.getItem('foo:bar')
// or
await storage.getItem('/foo/bar')
```
### `storage.hasItem(key)`
Checks if storage contains a key. Resolves to either `true` or `false`.
```js
await storage.hasItem('foo:bar')
```
### `storage.getItem(key)`
Gets value of a key in storage. Resolves to either `string` or `null`.
```js
await storage.getItem('foo:bar')
```
### `storage.setItem(key, value)`
Add Update a value to storage.
```js
await storage.setItem('foo:bar', 'baz')
```
### `storage.removeItem(key)`
Remove a value from storage.
```js
await storage.removeItem('foo:bar')
```
### `storage.getKeys()`
Get all keys. Returns an array of `string`.
```js
await storage.getKeys()
```
### `storage.clear()`
Removes all stored key/values.
```js
await storage.clear()
```
### `storage.mount(mountpoint, provider)`
By default, everything is stored in memory. We can mount additional storage space in a Unix-like fashion.
When operating with a `key` that starts with mountpoint, instead of default storage, mounted provider will be called.
```js
import { createStorage, fsStorage } from 'unistorage'
// Create a storage container with default memory storage
const storage = createStorage()
storage.mount('/output', fsStorage({ dir: './output' }))
// Writes to ./output/test file
await storage.setItem('/output/test', 'works')
// Adds value to in-memory storage
await storage.setItem('/foo', 'bar')
```
### `storage.dispose()`
Disposes all mounted storages to ensure there are no open-handles left. Call it before exiting process.
```js
await storage.dispose()
```
## Contribution
- Clone repository
- Install dependencies with `yarn install`
- Use `yarn dev` to start jest watcher verifying changes
- Use `yarn test` before push to ensure all tests and lint checks passing
## License
[MIT](./LICENSE)
<!-- Badges -->
[npm-version-src]: https://img.shields.io/npm/v/unstorage?style=flat-square
[npm-version-href]: https://npmjs.com/package/unstorage
[npm-downloads-src]: https://img.shields.io/npm/dm/unstorage?style=flat-square
[npm-downloads-href]: https://npmjs.com/package/unstorage
[github-actions-src]: https://img.shields.io/github/workflow/status/unjsio/unstorage/ci/main?style=flat-square
[github-actions-href]: https://github.com/unjsio/unstorage/actions?query=workflow%3Aci
[codecov-src]: https://img.shields.io/codecov/c/gh/unjsio/unstorage/main?style=flat-square
[codecov-href]: https://codecov.io/gh/unjsio/unstorage
[bundle-src]: https://img.shields.io/bundlephobia/minzip/unstorage?style=flat-square
[bundle-href]: https://bundlephobia.com/result?p=unstorage

5
jest.config.js Normal file
View File

@ -0,0 +1,5 @@
module.exports = {
preset: 'ts-jest',
collectCoverage: true,
testEnvironment: 'node'
}

37
package.json Normal file
View File

@ -0,0 +1,37 @@
{
"name": "unstorage",
"version": "0.0.0",
"description": "",
"repository": "unjsio/unstorage",
"license": "MIT",
"sideEffects": false,
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"files": [
"dist"
],
"scripts": {
"build": "siroc build",
"dev": "jest --watch",
"lint": "eslint --ext .ts .",
"prepublishOnly": "yarn build",
"release": "yarn test && standard-version && git push --follow-tags && npm publish",
"test": "yarn lint && jest"
},
"devDependencies": {
"@nuxtjs/eslint-config-typescript": "latest",
"@types/jest": "latest",
"@types/jsdom": "^16.2.6",
"@types/node": "latest",
"eslint": "latest",
"jest": "latest",
"jiti": "latest",
"jsdom": "^16.4.0",
"siroc": "latest",
"standard-version": "latest",
"ts-jest": "latest",
"typescript": "latest",
"vite": "^2.0.5"
}
}

5
renovate.json Normal file
View File

@ -0,0 +1,5 @@
{
"extends": [
"@nuxtjs"
]
}

3
src/index.ts Normal file
View File

@ -0,0 +1,3 @@
export * from './storage'
export * from './types'
export * from './providers'

38
src/providers/fs.ts Normal file
View File

@ -0,0 +1,38 @@
import { existsSync } from 'fs'
import { resolve } from 'path'
import type { StorageProviderFactory } from '../types'
import { readFile, writeFile, readdirRecursive, rmRecursive, unlink } from '../utils/node-fs'
export interface FSStorageOptions {
dir: string
}
export const fsStorage: StorageProviderFactory = (opts: FSStorageOptions) => {
if (!opts.dir) {
throw new Error('dir is required')
}
const r = (key: string) => resolve(opts.dir, key.replace(/:/g, '/'))
return {
hasItem (key) {
return existsSync(r(key))
},
getItem (key) {
return readFile(r(key))
},
setItem (key, value) {
return writeFile(r(key), value)
},
removeItem (key) {
return unlink(r(key))
},
getKeys () {
return readdirRecursive(r('.'))
},
async clear () {
await rmRecursive(r('.'))
},
dispose () {}
}
}

3
src/providers/index.ts Normal file
View File

@ -0,0 +1,3 @@
export * from './fs'
export * from './memory'
export * from './local'

33
src/providers/local.ts Normal file
View File

@ -0,0 +1,33 @@
import type { StorageProviderFactory } from '../types'
export interface LocalStorageOptions {
localStorage?: typeof window.localStorage
}
export const localStorage: StorageProviderFactory = (opts?: LocalStorageOptions) => {
const _localStorage = opts?.localStorage || (globalThis.localStorage as typeof window.localStorage)
if (!_localStorage) {
throw new Error('localStorage not available')
}
return {
hasItem (key) {
return Object.prototype.hasOwnProperty.call(_localStorage, key)
},
getItem (key) {
return _localStorage.getItem(key)
},
setItem (key, value) {
return _localStorage.setItem(key, value)
},
removeItem (key) {
return _localStorage.removeItem(key)
},
getKeys () {
return Object.keys(_localStorage)
},
clear () {
_localStorage.clear()
}
}
}

29
src/providers/memory.ts Normal file
View File

@ -0,0 +1,29 @@
import type { StorageProviderFactory, StorageValue } from '../types'
export const memoryStorage: StorageProviderFactory = () => {
const data = new Map<string, StorageValue>()
return {
hasItem (key) {
return data.has(key)
},
getItem (key) {
return data.get(key) || null
},
setItem (key, value) {
data.set(key, value)
},
removeItem (key) {
data.delete(key)
},
getKeys () {
return Array.from(data.keys())
},
clear () {
data.clear()
},
dispose () {
data.clear()
}
}
}

73
src/storage.ts Normal file
View File

@ -0,0 +1,73 @@
import type { Storage, StorageProvider } from './types'
import { memoryStorage } from './providers'
import { normalizeKey, asyncCall } from './utils'
export function createStorage (): Storage {
const defaultStorage = memoryStorage()
// TODO: refactor to SortedMap / SortedMap
const mounts: Record<string, StorageProvider> = {}
const mountKeys: string[] = [] // sorted keys of mounts
const getAllProviders = () => [defaultStorage, ...Object.values(mounts)]
const getProvider = (key: string) => {
for (const base of mountKeys) {
if (key.startsWith(base)) {
return mounts[base]
}
}
return defaultStorage
}
const storage: Storage = {
hasItem (key) {
key = normalizeKey(key)
return asyncCall(getProvider(key).hasItem, key)
},
getItem (key) {
key = normalizeKey(key)
return asyncCall(getProvider(key).getItem, key)
},
setItem (key, vlaue) {
key = normalizeKey(key)
return asyncCall(getProvider(key).setItem, key, vlaue)
},
removeItem (key) {
key = normalizeKey(key)
return asyncCall(getProvider(key).removeItem, key)
},
async getKeys () {
const providerKeys = await Promise.all(getAllProviders().map(s => asyncCall(s.getKeys)))
return providerKeys.flat().map(normalizeKey)
},
async clear () {
await Promise.all(getAllProviders().map(s => asyncCall(s.clear)))
},
async dispose () {
await Promise.all(getAllProviders().map(s => disposeStoage(s)))
},
mount (base, provider) {
base = normalizeKey(base)
if (!mountKeys.includes(base)) {
mountKeys.push(base)
mountKeys.sort((a, b) => b.length - a.length)
}
if (mounts[base]) {
if (mounts[base].dispose) {
// eslint-disable-next-line no-console
disposeStoage(mounts[base]!).catch(console.error)
}
delete mounts[base]
}
mounts[base] = provider
}
}
return storage
}
async function disposeStoage (storage: StorageProvider) {
if (typeof storage.dispose === 'function') {
await asyncCall(storage.dispose)
}
}

24
src/types.ts Normal file
View File

@ -0,0 +1,24 @@
export type StorageValue = string | null
export interface StorageProvider {
hasItem: (key: string) => boolean | Promise<boolean>
getItem: (key: string) => StorageValue | Promise<StorageValue>
setItem: (key: string, value: string) => void | Promise<void>
removeItem: (key: string) => void | Promise<void>
getKeys: () => string[] | Promise<string[]>
clear: () => void | Promise<void>
dispose?: () => void | Promise<void>
}
export type StorageProviderFactory<OptsT = any> = (opts?: OptsT) => StorageProvider
export interface Storage {
hasItem: (key: string) => Promise<boolean>
getItem: (key: string) => Promise<StorageValue>
setItem: (key: string, value: string) => Promise<void>
removeItem: (key: string) => Promise<void>
getKeys: () => Promise<string[]>
clear: () => Promise<void>
mount: (mountpoint: string, provider: StorageProvider) => void
dispose: () => Promise<void>
}

21
src/utils/index.ts Normal file
View File

@ -0,0 +1,21 @@
export function normalizeKey (key: string) {
return key.replace(/[/\\]/g, ':').replace(/^:|:$/g, '')
}
type Awaited<T> = T extends Promise<infer U> ? Awaited<U> : T;
type Promisified<T> = Promise<Awaited<T>>
export function wrapToPromise<T> (val: T): Promisified<T> {
if (!val || typeof (val as any).then !== 'function') {
return Promise.resolve(val) as Promisified<T>
}
return val as unknown as Promisified<T>
}
export function asyncCall<T extends (...args: any) => any>(fn: T, ...args: any[]): Promisified<ReturnType<T>> {
try {
return wrapToPromise(fn(...args))
} catch (err) {
return Promise.reject(err)
}
}

64
src/utils/node-fs.ts Normal file
View File

@ -0,0 +1,64 @@
import { promises as fsPromises } from 'fs'
import { resolve, dirname } from 'path'
import { Dirent } from 'node:fs'
function isNotFound (err: any) {
return err.code === 'ENOENT'
}
export async function writeFile (path: string, data: string) {
await ensuredir(dirname(path))
return fsPromises.writeFile(path, data, 'utf8')
}
export function readFile (path: string) {
return fsPromises.readFile(path, 'utf8')
.catch(err => isNotFound(err) ? null : Promise.reject(err))
}
export function stat (path: string) {
return fsPromises.stat(path)
.catch(err => isNotFound(err) ? null : Promise.reject(err))
}
export function unlink (path: string) {
return fsPromises.unlink(path)
.catch(err => isNotFound(err) ? undefined : Promise.reject(err))
}
export async function ensuredir (dir: string) {
const _stat = await stat(dir)
if (_stat && _stat.isDirectory()) {
return
}
await ensuredir(dirname(dir))
await fsPromises.mkdir(dir)
}
export async function readdirRecursive (dir: string): Promise<string[]> {
const entries: Dirent[] = await fsPromises.readdir(dir, { withFileTypes: true })
.catch(err => isNotFound(err) ? [] : Promise.reject(err))
const files: string[] = []
await Promise.all(entries.map(async (entry) => {
const entryPath = resolve(dir, entry.name)
if (entry.isDirectory()) {
const dirFiles = await readdirRecursive(entryPath)
files.push(...dirFiles.map(f => entry.name + '/' + f))
} else {
files.push(entry.name)
}
}))
return files
}
export async function rmRecursive (dir: string): Promise<void> {
const entries = await fsPromises.readdir(dir, { withFileTypes: true })
await Promise.all(entries.map((entry) => {
const entryPath = resolve(dir, entry.name)
if (entry.isDirectory()) {
return rmRecursive(entryPath).then(() => fsPromises.rmdir(entryPath))
} else {
return fsPromises.unlink(entryPath)
}
}))
}

57
test/index.test.ts Normal file
View File

@ -0,0 +1,57 @@
import { resolve } from 'path'
import { JSDOM } from 'jsdom'
import {
Storage, StorageProvider, createStorage,
memoryStorage, fsStorage, localStorage
} from '../src'
describe('memoryStorage', () => {
testProvider(() => memoryStorage())
})
describe('fsStorage', () => {
testProvider(() => fsStorage({
dir: resolve(__dirname, 'tmp')
}))
})
describe('localStorage', () => {
testProvider(() => {
const jsdom = new JSDOM('', {
url: 'http://localhost'
})
return localStorage({
localStorage: jsdom.window.localStorage
})
})
})
function testProvider (getProvider: () => StorageProvider | Promise<StorageProvider>) {
let storage: Storage
it('init', async () => {
storage = createStorage()
const provider = await getProvider()
storage.mount('/', provider)
await storage.clear()
})
it('initial state', async () => {
expect(await storage.hasItem('foo:bar')).toBe(false)
expect(await storage.getItem('foo:bar')).toBe(null)
expect(await storage.getKeys()).toMatchObject([])
})
it('setItem', async () => {
await storage.setItem('/foo:bar/', 'test_data')
expect(await storage.hasItem('foo:bar')).toBe(true)
expect(await storage.getItem('foo:bar')).toBe('test_data')
expect(await storage.getKeys()).toMatchObject(['foo:bar'])
})
it('removeItem', async () => {
await storage.removeItem('foo:bar')
expect(await storage.hasItem('foo:bar')).toBe(false)
expect(await storage.getItem('foo:bar')).toBe(null)
expect(await storage.getKeys()).toMatchObject([])
})
}

20
tsconfig.json Normal file
View File

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ESNext",
"lib": [
"ES2019",
"DOM"
],
"moduleResolution": "Node",
"esModuleInterop": true,
"outDir": "dist",
"strict": true,
"types": [
"node",
"jest"
]
},
"include": [
"src"
]
}

6078
yarn.lock Normal file

File diff suppressed because it is too large Load Diff