style: format and lint code

This commit is contained in:
Pooya Parsa
2023-02-06 11:15:06 +01:00
parent 2cf66975bd
commit fd4e00644d
36 changed files with 1105 additions and 930 deletions

View File

@@ -1,9 +1,8 @@
{
"extends": [
"eslint-config-unjs"
],
"extends": ["eslint-config-unjs"],
"rules": {
"unicorn/no-null": 0,
"unicorn/prevent-abbreviations": 0
"unicorn/prevent-abbreviations": 0,
"@typescript-eslint/no-non-null-assertion": 0
}
}

0
.prettierrc Normal file
View File

View File

@@ -4,64 +4,76 @@
<FileTree @open="openTab" />
<div class="main">
<div class="tabs">
<div v-for="tab in state.tabs" :key="tab.path" class="tab" :class="{active: state.path === tab.path }">
<span @click="openTab(tab.path)">{{ tab.name }} </span><span @click="closeTab(tab.path)">(x)</span>
<div
v-for="tab in state.tabs"
:key="tab.path"
class="tab"
:class="{ active: state.path === tab.path }"
>
<span @click="openTab(tab.path)">{{ tab.name }} </span
><span @click="closeTab(tab.path)">(x)</span>
</div>
</div>
<Editor class="editor" :value="state.source" :language="state.language" />
<Editor
class="editor"
:value="state.source"
:language="state.language"
/>
</div>
</div>
</Suspense>
</template>
<script>
import { defineComponent, reactive, inject } from 'vue'
import Editor from './Editor.vue'
import FileTree from './FileTree.vue'
import { defineComponent, reactive, inject } from "vue";
import Editor from "./Editor.vue";
import FileTree from "./FileTree.vue";
export default defineComponent({
components: {
Editor,
FileTree
FileTree,
},
setup () {
const storage = inject('storage')
setup() {
const storage = inject("storage");
const state = reactive({
tabs: [],
path: undefined,
source: '',
language: 'javascript'
})
source: "",
language: "javascript",
});
const openTab = async (path) => {
const tab = state.tabs.find(tab => tab.path === path)
const tab = state.tabs.find((tab) => tab.path === path);
if (!tab) {
state.tabs.push({
name: path.split(':').pop(),
path
})
name: path.split(":").pop(),
path,
});
}
const source = await storage.getItem(path)
state.language = path.split(':').pop().split('.').pop() || 'javascript'
state.path = path
state.source = source
}
const source = await storage.getItem(path);
state.language = path.split(":").pop().split(".").pop() || "javascript";
state.path = path;
state.source = source;
};
const closeTab = (path) => {
state.tabs = state.tabs.filter(t => t.path !== path)
}
state.tabs = state.tabs.filter((t) => t.path !== path);
};
return {
state,
openTab,
closeTab
}
}
})
closeTab,
};
},
});
</script>
<style>
body, html, #app {
body,
html,
#app {
margin: 0;
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;

View File

@@ -3,35 +3,35 @@
</template>
<script>
import { defineComponent } from 'vue'
import * as monaco from 'monaco-editor'
import { defineComponent } from "vue";
import * as monaco from "monaco-editor";
export default defineComponent({
props: {
value: {
type: String,
required: true
required: true,
},
language: {
type: String,
default: 'auto'
}
default: "auto",
},
},
watch: {
value (newValue) {
this.editor.setValue(newValue)
value(newValue) {
this.editor.setValue(newValue);
},
language(newValue) {
this.editor.setModelLanguage(this.editor.getModule(), newValue);
},
language (newValue) {
this.editor.setModelLanguage(this.editor.getModule(), newValue)
}
},
mounted () {
mounted() {
this.editor = monaco.editor.create(this.$refs.editor, {
value: this.value,
language: this.language
})
}
})
language: this.language,
});
},
});
</script>
<style scoped>

View File

@@ -1,52 +1,51 @@
<template>
<div class="filetree">
<file-tree-node
:item="tree"
@open="path => $emit('open', path)"
/>
<file-tree-node :item="tree" @open="(path) => $emit('open', path)" />
</div>
</template>
<script>
import { defineComponent, inject, ref } from 'vue'
import FileTreeNode from './FileTreeNode.vue'
import { defineComponent, inject, ref } from "vue";
import FileTreeNode from "./FileTreeNode.vue";
function unflattenArray (items, toplevelKey = 'root') {
const res = { name: toplevelKey, path: '', children: [] }
function unflattenArray(items, toplevelKey = "root") {
const res = { name: toplevelKey, path: "", children: [] };
for (const item of items) {
const split = item.split(':')
let target = res
const split = item.split(":");
let target = res;
for (const name of split) {
let child = target.children.find(c => c.name === name)
let child = target.children.find((c) => c.name === name);
if (!child) {
child = {
name,
path: target.path + ':' + name,
children: []
}
target.children.push(child)
target.children = target.children.sort((c1, c2) => c1.name.localeCompare(c2.name))
path: target.path + ":" + name,
children: [],
};
target.children.push(child);
target.children = target.children.sort((c1, c2) =>
c1.name.localeCompare(c2.name)
);
}
target = child
target = child;
}
target.path = item
target.path = item;
}
return res
return res;
}
export default defineComponent({
components: { FileTreeNode },
async setup () {
const storage = inject('storage')
const tree = ref([])
async setup() {
const storage = inject("storage");
const tree = ref([]);
await storage.getKeys().then((_keys) => {
tree.value = unflattenArray(_keys, 'workspace')
})
tree.value = unflattenArray(_keys, "workspace");
});
return {
tree
}
}
})
tree,
};
},
});
</script>
<style scoped>

View File

@@ -1,49 +1,49 @@
<template>
<li class="filetree-node">
<div @click="onOpen">
{{ item.name }} <span v-if="isDirectory">[{{ isOpen ? '-' : '+' }}]</span>
{{ item.name }} <span v-if="isDirectory">[{{ isOpen ? "-" : "+" }}]</span>
</div>
<ul v-if="isOpen && item.children.length">
<file-tree-node
v-for="child of item.children"
:key="child.name"
:item="child"
@open="path => $emit('open', path)"
@open="(path) => $emit('open', path)"
/>
</ul>
</li>
</template>
<script>
import { defineComponent, ref } from 'vue'
import { defineComponent, ref } from "vue";
export default defineComponent({
props: {
item: {
type: Object,
required: true
}
required: true,
},
},
setup (props) {
setup(props) {
return {
isOpen: ref((props.item.path || '').split(':').length < 3)
}
isOpen: ref((props.item.path || "").split(":").length < 3),
};
},
computed: {
isDirectory () {
return this.item.children.length
}
isDirectory() {
return this.item.children.length;
},
},
methods: {
onOpen () {
onOpen() {
if (this.isDirectory) {
this.isOpen = !this.isOpen
this.isOpen = !this.isOpen;
} else {
this.$emit('open', this.item.path)
this.$emit("open", this.item.path);
}
}
}
})
},
},
});
</script>
<style scoped>

View File

@@ -1,15 +1,15 @@
import { createApp } from 'vue'
import { createStorage } from '../src'
import httpDriver from '../src/drivers/http'
import App from './App.vue'
import { createApp } from "vue";
import { createStorage } from "../src";
import httpDriver from "../src/drivers/http";
import App from "./App.vue";
function main () {
function main() {
const storage = createStorage({
driver: httpDriver({ base: location.origin + '/storage' })
})
const app = createApp(App)
app.provide('storage', storage)
app.mount('#app')
driver: httpDriver({ base: location.origin + "/storage" }),
});
const app = createApp(App);
app.provide("storage", storage);
app.mount("#app");
}
main()
main();

View File

@@ -9,20 +9,20 @@ import fsdriver from "../src/drivers/fs";
export default defineConfig({
resolve: {
alias: {
"node-fetch": "node-fetch/browser"
}
"node-fetch": "node-fetch/browser",
},
},
plugins: [
vue(),
{
name: "app",
configureServer (server) {
configureServer(server) {
const storage = createStorage();
const storageServer = createStorageServer(storage);
// eslint-disable-next-line unicorn/prefer-module
storage.mount("/src", fsdriver({ base: resolve(__dirname, "..") }));
server.middlewares.use("/storage", storageServer.handle);
}
}
]
},
},
],
});

View File

@@ -1,14 +1,17 @@
type Awaited<T> = T extends Promise<infer U> ? Awaited<U> : T;
type Promisified<T> = Promise<Awaited<T>>
type Promisified<T> = Promise<Awaited<T>>;
export function wrapToPromise<T> (value: T) {
export function wrapToPromise<T>(value: T) {
if (!value || typeof (value as any).then !== "function") {
return Promise.resolve(value) as Promisified<T>;
}
return value as unknown as Promisified<T>;
}
export function asyncCall<T extends (...arguments_: any) => any>(function_: T, ...arguments_: any[]): Promisified<ReturnType<T>> {
export function asyncCall<T extends (...arguments_: any) => any>(
function_: T,
...arguments_: any[]
): Promisified<ReturnType<T>> {
try {
return wrapToPromise(function_(...arguments_));
} catch (error) {
@@ -16,11 +19,11 @@ export function asyncCall<T extends (...arguments_: any) => any>(function_: T, .
}
}
export function isPrimitive (argument: any) {
export function isPrimitive(argument: any) {
const type = typeof argument;
return argument === null || (type !== "object" && type !== "function");
}
export function stringify (argument: any) {
return isPrimitive(argument) ? (argument + "") : JSON.stringify(argument);
export function stringify(argument: any) {
return isPrimitive(argument) ? argument + "" : JSON.stringify(argument);
}

View File

@@ -5,7 +5,7 @@ import { createStorage } from "./storage";
import { createStorageServer } from "./server";
import fsDriver from "./drivers/fs";
async function main () {
async function main() {
const arguments_ = mri(process.argv.splice(2));
if (arguments_.help) {
@@ -18,14 +18,14 @@ async function main () {
const rootDir = resolve(arguments_._[0] || ".");
const storage = createStorage({
driver: fsDriver({ base: rootDir })
driver: fsDriver({ base: rootDir }),
});
const storageServer = createStorageServer(storage);
await listen(storageServer.handle, {
name: "Storage server",
port: 8080
port: 8080,
});
}

View File

@@ -1,59 +1,62 @@
/// <reference types="@cloudflare/workers-types" />
import { defineDriver } from './utils'
import { defineDriver } from "./utils";
export interface KVOptions {
binding?: string | KVNamespace
binding?: string | KVNamespace;
}
// https://developers.cloudflare.com/workers/runtime-apis/kv
export default defineDriver((opts: KVOptions = {}) => {
const binding = getBinding(opts.binding)
const binding = getBinding(opts.binding);
async function getKeys(base?: string) {
const kvList = await binding.list(base ? { prefix: base } : undefined)
return kvList.keys.map(key => key.name)
const kvList = await binding.list(base ? { prefix: base } : undefined);
return kvList.keys.map((key) => key.name);
}
return {
async hasItem(key) {
return (await binding.get(key)) !== null
return (await binding.get(key)) !== null;
},
getItem(key) {
return binding.get(key)
return binding.get(key);
},
setItem(key, value) {
return binding.put(key, value)
return binding.put(key, value);
},
removeItem(key) {
return binding.delete(key)
return binding.delete(key);
},
// TODO: use this.getKeys once core is fixed
getKeys,
async clear() {
const keys = await getKeys()
await Promise.all(keys.map(key => binding.delete(key)))
}
}
})
const keys = await getKeys();
await Promise.all(keys.map((key) => binding.delete(key)));
},
};
});
function getBinding(binding: KVNamespace | string = "STORAGE") {
let bindingName = "[binding]";
function getBinding(binding: KVNamespace | string = 'STORAGE') {
let bindingName = '[binding]'
if (typeof binding === 'string') {
bindingName = binding
binding = (globalThis as any)[bindingName] as KVNamespace
if (typeof binding === "string") {
bindingName = binding;
binding = (globalThis as any)[bindingName] as KVNamespace;
}
if (!binding) {
throw new Error(`Invalid Cloudflare KV binding '${bindingName}': ${binding}`)
throw new Error(
`Invalid Cloudflare KV binding '${bindingName}': ${binding}`
);
}
for (const key of ['get', 'put', 'delete']) {
for (const key of ["get", "put", "delete"]) {
if (!(key in binding)) {
throw new Error(`Invalid Cloudflare KV binding '${bindingName}': '${key}' key is missing`)
throw new Error(
`Invalid Cloudflare KV binding '${bindingName}': '${key}' key is missing`
);
}
}
return binding
return binding;
}

View File

@@ -1,7 +1,7 @@
import { $fetch } from 'ofetch'
import { defineDriver } from './utils'
import { $fetch } from "ofetch";
import { defineDriver } from "./utils";
const LOG_TAG = '[unstorage] [cloudflare-http] '
const LOG_TAG = "[unstorage] [cloudflare-http] ";
interface KVAuthAPIToken {
/**
@@ -9,7 +9,7 @@ interface KVAuthAPIToken {
* of the Cloudflare console.
* @see https://api.cloudflare.com/#getting-started-requests
*/
apiToken: string
apiToken: string;
}
interface KVAuthServiceKey {
@@ -19,7 +19,7 @@ interface KVAuthServiceKey {
* May be used to authenticate in place of `apiToken` or `apiKey` and `email`.
* @see https://api.cloudflare.com/#getting-started-requests
*/
userServiceKey: string
userServiceKey: string;
}
interface KVAuthEmailKey {
@@ -27,150 +27,170 @@ interface KVAuthEmailKey {
* Email address associated with your account.
* Should be used along with `apiKey` to authenticate in place of `apiToken`.
*/
email: string
email: string;
/**
* API key generated on the "My Account" page of the Cloudflare console.
* Should be used along with `email` to authenticate in place of `apiToken`.
* @see https://api.cloudflare.com/#getting-started-requests
*/
apiKey: string
apiKey: string;
}
export type KVHTTPOptions = {
/**
* Cloudflare account ID (required)
*/
accountId: string
accountId: string;
/**
* The ID of the KV namespace to target (required)
*/
namespaceId: string
namespaceId: string;
/**
* The URL of the Cloudflare API.
* @default https://api.cloudflare.com
*/
apiURL?: string
} & (KVAuthServiceKey | KVAuthAPIToken | KVAuthEmailKey)
apiURL?: string;
} & (KVAuthServiceKey | KVAuthAPIToken | KVAuthEmailKey);
type CloudflareAuthorizationHeaders = {
'X-Auth-Email': string
'X-Auth-Key': string
'X-Auth-User-Service-Key'?: string
Authorization?: `Bearer ${string}`
} | {
'X-Auth-Email'?: string
'X-Auth-Key'?: string
'X-Auth-User-Service-Key': string
Authorization?: `Bearer ${string}`
} | {
'X-Auth-Email'?: string
'X-Auth-Key'?: string
'X-Auth-User-Service-Key'?: string
Authorization: `Bearer ${string}`
}
type CloudflareAuthorizationHeaders =
| {
"X-Auth-Email": string;
"X-Auth-Key": string;
"X-Auth-User-Service-Key"?: string;
Authorization?: `Bearer ${string}`;
}
| {
"X-Auth-Email"?: string;
"X-Auth-Key"?: string;
"X-Auth-User-Service-Key": string;
Authorization?: `Bearer ${string}`;
}
| {
"X-Auth-Email"?: string;
"X-Auth-Key"?: string;
"X-Auth-User-Service-Key"?: string;
Authorization: `Bearer ${string}`;
};
export default defineDriver<KVHTTPOptions>((opts) => {
if (!opts.accountId) {
throw new Error(LOG_TAG + '`accountId` is required.')
throw new Error(LOG_TAG + "`accountId` is required.");
}
if (!opts.namespaceId) {
throw new Error(LOG_TAG + '`namespaceId` is required.')
throw new Error(LOG_TAG + "`namespaceId` is required.");
}
let headers: CloudflareAuthorizationHeaders
let headers: CloudflareAuthorizationHeaders;
if ('apiToken' in opts) {
headers = { Authorization: `Bearer ${opts.apiToken}` }
} else if ('userServiceKey' in opts) {
headers = { 'X-Auth-User-Service-Key': opts.userServiceKey }
if ("apiToken" in opts) {
headers = { Authorization: `Bearer ${opts.apiToken}` };
} else if ("userServiceKey" in opts) {
headers = { "X-Auth-User-Service-Key": opts.userServiceKey };
} else if (opts.email && opts.apiKey) {
headers = { 'X-Auth-Email': opts.email, 'X-Auth-Key': opts.apiKey }
headers = { "X-Auth-Email": opts.email, "X-Auth-Key": opts.apiKey };
} else {
throw new Error(
LOG_TAG + 'One of the `apiToken`, `userServiceKey`, or a combination of `email` and `apiKey` is required.'
)
LOG_TAG +
"One of the `apiToken`, `userServiceKey`, or a combination of `email` and `apiKey` is required."
);
}
const apiURL = opts.apiURL || 'https://api.cloudflare.com'
const baseURL = `${apiURL}/client/v4/accounts/${opts.accountId}/storage/kv/namespaces/${opts.namespaceId}`
const kvFetch = $fetch.create({ baseURL, headers })
const apiURL = opts.apiURL || "https://api.cloudflare.com";
const baseURL = `${apiURL}/client/v4/accounts/${opts.accountId}/storage/kv/namespaces/${opts.namespaceId}`;
const kvFetch = $fetch.create({ baseURL, headers });
const hasItem = async (key: string) => {
try {
const res = await kvFetch(`/metadata/${key}`)
return res?.success === true
const res = await kvFetch(`/metadata/${key}`);
return res?.success === true;
} catch (err) {
if (!err.response) { throw err }
if (err.response.status === 404) { return false }
throw err
if (!err.response) {
throw err;
}
if (err.response.status === 404) {
return false;
}
throw err;
}
}
};
const getItem = async (key: string) => {
try {
// Cloudflare API returns with `content-type: application/octet-stream`
return await kvFetch(`/values/${key}`).then(r => r.text())
return await kvFetch(`/values/${key}`).then((r) => r.text());
} catch (err) {
if (!err.response) { throw err }
if (err.response.status === 404) { return null }
throw err
if (!err.response) {
throw err;
}
if (err.response.status === 404) {
return null;
}
throw err;
}
}
};
const setItem = async (key: string, value: any) => {
return await kvFetch(`/values/${key}`, { method: 'PUT', body: value })
}
return await kvFetch(`/values/${key}`, { method: "PUT", body: value });
};
const removeItem = async (key: string) => {
return await kvFetch(`/values/${key}`, { method: 'DELETE' })
}
return await kvFetch(`/values/${key}`, { method: "DELETE" });
};
const getKeys = async (base?: string) => {
const keys: string[] = []
const keys: string[] = [];
const params: Record<string, string> = {}
const params: Record<string, string> = {};
if (base) {
params.prefix = base
params.prefix = base;
}
const firstPage = await kvFetch('/keys', { params })
firstPage.result.forEach(({ name }: { name: string }) => keys.push(name))
const firstPage = await kvFetch("/keys", { params });
firstPage.result.forEach(({ name }: { name: string }) => keys.push(name));
const cursor = firstPage.result_info.cursor
const cursor = firstPage.result_info.cursor;
if (cursor) {
params.cursor = cursor
params.cursor = cursor;
}
while (params.cursor) {
const pageResult = await kvFetch('/keys', { params })
pageResult.result.forEach(({ name }: { name: string }) => keys.push(name))
const pageCursor = pageResult.result_info.cursor
const pageResult = await kvFetch("/keys", { params });
pageResult.result.forEach(({ name }: { name: string }) =>
keys.push(name)
);
const pageCursor = pageResult.result_info.cursor;
if (pageCursor) {
params.cursor = pageCursor
params.cursor = pageCursor;
} else {
params.cursor = undefined
params.cursor = undefined;
}
}
return keys
}
return keys;
};
const clear = async () => {
const keys: string[] = await getKeys()
const keys: string[] = await getKeys();
// Split into chunks of 10000, as the API only allows for 10,000 keys at a time
const chunks = keys.reduce((acc, key, i) => {
if (i % 10000 === 0) { acc.push([]) }
acc[acc.length - 1].push(key)
return acc
}, [[]])
const chunks = keys.reduce(
(acc, key, i) => {
if (i % 10000 === 0) {
acc.push([]);
}
acc[acc.length - 1].push(key);
return acc;
},
[[]]
);
// Call bulk delete endpoint with each chunk
await Promise.all(chunks.map((chunk) => {
return kvFetch('/bulk', {
method: 'DELETE',
body: { keys: chunk },
await Promise.all(
chunks.map((chunk) => {
return kvFetch("/bulk", {
method: "DELETE",
body: { keys: chunk },
});
})
}))
}
);
};
return {
hasItem,
@@ -179,5 +199,5 @@ export default defineDriver<KVHTTPOptions>((opts) => {
removeItem,
getKeys,
clear,
}
})
};
});

View File

@@ -1,93 +1,100 @@
import { existsSync, promises as fsp } from 'fs'
import { resolve, relative, join } from 'path'
import { FSWatcher, WatchOptions, watch } from 'chokidar'
import { defineDriver } from './utils'
import { readFile, writeFile, readdirRecursive, rmRecursive, unlink } from './utils/node-fs'
import anymatch from 'anymatch'
import { existsSync, promises as fsp } from "fs";
import { resolve, relative, join } from "path";
import { FSWatcher, WatchOptions, watch } from "chokidar";
import { defineDriver } from "./utils";
import {
readFile,
writeFile,
readdirRecursive,
rmRecursive,
unlink,
} from "./utils/node-fs";
import anymatch from "anymatch";
export interface FSStorageOptions {
base?: string
ignore?: string[]
watchOptions?: WatchOptions
base?: string;
ignore?: string[];
watchOptions?: WatchOptions;
}
const PATH_TRAVERSE_RE = /\.\.\:|\.\.$/
const PATH_TRAVERSE_RE = /\.\.\:|\.\.$/;
export default defineDriver((opts: FSStorageOptions = {}) => {
if (!opts.base) {
throw new Error('base is required')
throw new Error("base is required");
}
if (!opts.ignore) {
opts.ignore = [
'**/node_modules/**',
'**/.git/**'
]
opts.ignore = ["**/node_modules/**", "**/.git/**"];
}
opts.base = resolve(opts.base)
opts.base = resolve(opts.base);
const r = (key: string) => {
if (PATH_TRAVERSE_RE.test(key)) {
throw new Error('[unstorage] [fs] Invalid key. It should not contain `..` segments: ' + key)
throw new Error(
"[unstorage] [fs] Invalid key. It should not contain `..` segments: " +
key
);
}
const resolved = join(opts.base!, key.replace(/:/g, '/'))
return resolved
}
const resolved = join(opts.base!, key.replace(/:/g, "/"));
return resolved;
};
let _watcher: FSWatcher
let _watcher: FSWatcher;
return {
hasItem (key) {
return existsSync(r(key))
hasItem(key) {
return existsSync(r(key));
},
getItem (key) {
return readFile(r(key))
getItem(key) {
return readFile(r(key));
},
async getMeta (key) {
const { atime, mtime, size } = await fsp.stat(r(key))
.catch(() => ({ atime: undefined, mtime: undefined, size: undefined }))
return { atime, mtime, size }
async getMeta(key) {
const { atime, mtime, size } = await fsp
.stat(r(key))
.catch(() => ({ atime: undefined, mtime: undefined, size: undefined }));
return { atime, mtime, size };
},
setItem (key, value) {
return writeFile(r(key), value)
setItem(key, value) {
return writeFile(r(key), value);
},
removeItem (key) {
return unlink(r(key))
removeItem(key) {
return unlink(r(key));
},
getKeys () {
return readdirRecursive(r('.'), anymatch(opts.ignore || []))
getKeys() {
return readdirRecursive(r("."), anymatch(opts.ignore || []));
},
async clear () {
await rmRecursive(r('.'))
async clear() {
await rmRecursive(r("."));
},
async dispose() {
if (_watcher) {
await _watcher.close()
await _watcher.close();
}
},
},
watch(callback) {
if (_watcher) {
return
return;
}
return new Promise((resolve, reject) => {
_watcher = watch(opts.base!, {
ignoreInitial: true,
ignored: opts.ignore,
...opts.watchOptions
...opts.watchOptions,
})
.on('ready', () => {
resolve(() => _watcher.close().then(() => _watcher = undefined))
.on("ready", () => {
resolve(() => _watcher.close().then(() => (_watcher = undefined)));
})
.on('error', reject)
.on('all', (eventName, path) => {
path = relative(opts.base!, path)
if (eventName === 'change' || eventName === 'add') {
callback('update', path)
} else if (eventName === 'unlink') {
callback('remove', path)
.on("error", reject)
.on("all", (eventName, path) => {
path = relative(opts.base!, path);
if (eventName === "change" || eventName === "add") {
callback("update", path);
} else if (eventName === "unlink") {
callback("remove", path);
}
})
})
}
}
})
});
});
},
};
});

View File

@@ -1,141 +1,148 @@
import { defineDriver } from './utils'
import { $fetch } from 'ofetch'
import { withTrailingSlash, joinURL } from 'ufo'
import { defineDriver } from "./utils";
import { $fetch } from "ofetch";
import { withTrailingSlash, joinURL } from "ufo";
export interface GithubOptions {
/**
* The name of the repository. (e.g. `username/my-repo`)
* Required
*/
repo: string
repo: string;
/**
* The branch to fetch. (e.g. `dev`)
* @default "main"
*/
branch: string
branch: string;
/**
* @default ""
*/
dir: string
dir: string;
/**
* @default 600
*/
ttl: number
ttl: number;
/**
* Github API token (recommended)
*/
token?: string
token?: string;
/**
* @default "https://api.github.com"
*/
apiURL?: string
apiURL?: string;
/**
* @default "https://raw.githubusercontent.com"
*/
cdnURL?: string
cdnURL?: string;
}
const defaultOptions: GithubOptions = {
repo: null,
branch: 'main',
branch: "main",
ttl: 600,
dir: '',
apiURL: 'https://api.github.com',
cdnURL: 'https://raw.githubusercontent.com'
}
dir: "",
apiURL: "https://api.github.com",
cdnURL: "https://raw.githubusercontent.com",
};
export default defineDriver((_opts: GithubOptions) => {
const opts = { ...defaultOptions, ..._opts }
const rawUrl = joinURL(opts.cdnURL, opts.repo, opts.branch, opts.dir)
const opts = { ...defaultOptions, ..._opts };
const rawUrl = joinURL(opts.cdnURL, opts.repo, opts.branch, opts.dir);
let files = {}
let lastCheck = 0
let syncPromise: Promise<any>
let files = {};
let lastCheck = 0;
let syncPromise: Promise<any>;
if (!opts.repo) {
throw new Error('[unstorage] [github] Missing required option "repo"')
throw new Error('[unstorage] [github] Missing required option "repo"');
}
const syncFiles = async () => {
if ((lastCheck + opts.ttl * 1000) > Date.now()) {
return
if (lastCheck + opts.ttl * 1000 > Date.now()) {
return;
}
if (!syncPromise) {
syncPromise = fetchFiles(opts)
syncPromise = fetchFiles(opts);
}
files = await syncPromise
lastCheck = Date.now()
syncPromise = undefined
}
files = await syncPromise;
lastCheck = Date.now();
syncPromise = undefined;
};
return {
async getKeys() {
await syncFiles()
return Object.keys(files)
await syncFiles();
return Object.keys(files);
},
async hasItem(key) {
await syncFiles()
return key in files
await syncFiles();
return key in files;
},
async getItem(key) {
await syncFiles()
await syncFiles();
const item = files[key]
const item = files[key];
if (!item) {
return null
return null;
}
if (!item.body) {
try {
item.body = await $fetch(key.replace(/:/g, '/'), {
item.body = await $fetch(key.replace(/:/g, "/"), {
baseURL: rawUrl,
headers: {
Authorization: opts.token ? `token ${opts.token}` : undefined
}
})
Authorization: opts.token ? `token ${opts.token}` : undefined,
},
});
} catch (err) {
throw new Error(`[unstorage] [github] Failed to fetch "${key}"`, { cause: err })
throw new Error(`[unstorage] [github] Failed to fetch "${key}"`, {
cause: err,
});
}
}
return item.body
return item.body;
},
async getMeta(key) {
await syncFiles()
const item = files[key]
return item ? item.meta : null
}
}
})
await syncFiles();
const item = files[key];
return item ? item.meta : null;
},
};
});
async function fetchFiles(opts: GithubOptions) {
const prefix = withTrailingSlash(opts.dir).replace(/^\//, '')
const files = {}
const prefix = withTrailingSlash(opts.dir).replace(/^\//, "");
const files = {};
try {
const trees = await $fetch(`/repos/${opts.repo}/git/trees/${opts.branch}?recursive=1`, {
baseURL: opts.apiURL,
headers: {
Authorization: opts.token ? `token ${opts.token}` : undefined
const trees = await $fetch(
`/repos/${opts.repo}/git/trees/${opts.branch}?recursive=1`,
{
baseURL: opts.apiURL,
headers: {
Authorization: opts.token ? `token ${opts.token}` : undefined,
},
}
})
);
for (const node of trees.tree) {
if (node.type !== 'blob' || !node.path.startsWith(prefix)) {
continue
if (node.type !== "blob" || !node.path.startsWith(prefix)) {
continue;
}
const key = node.path.substring(prefix.length).replace(/\//g, ':')
const key = node.path.substring(prefix.length).replace(/\//g, ":");
files[key] = {
meta: {
sha: node.sha,
mode: node.mode,
size: node.size
}
}
size: node.size,
},
};
}
} catch (err) {
throw new Error(`[unstorage] [github] Failed to fetch git tree`, { cause: err })
throw new Error(`[unstorage] [github] Failed to fetch git tree`, {
cause: err,
});
}
return files
return files;
}

View File

@@ -1,47 +1,49 @@
import { defineDriver } from './utils'
import { stringify } from './utils'
import { $fetch } from 'ofetch'
import { joinURL } from 'ufo'
import { defineDriver } from "./utils";
import { stringify } from "./utils";
import { $fetch } from "ofetch";
import { joinURL } from "ufo";
export interface HTTPOptions {
base?: string
base?: string;
}
export default defineDriver((opts: HTTPOptions = {}) => {
const r = (key: string) => joinURL(opts.base!, key.replace(/:/g, '/'))
const r = (key: string) => joinURL(opts.base!, key.replace(/:/g, "/"));
return {
hasItem(key) {
return $fetch(r(key), { method: 'HEAD' })
return $fetch(r(key), { method: "HEAD" })
.then(() => true)
.catch(() => false)
.catch(() => false);
},
async getItem (key) {
const value = await $fetch(r(key))
return value
async getItem(key) {
const value = await $fetch(r(key));
return value;
},
async getMeta (key) {
const res = await $fetch.raw(r(key), { method: 'HEAD' })
let mtime = undefined
const _lastModified = res.headers.get('last-modified')
if (_lastModified) { mtime = new Date(_lastModified) }
async getMeta(key) {
const res = await $fetch.raw(r(key), { method: "HEAD" });
let mtime = undefined;
const _lastModified = res.headers.get("last-modified");
if (_lastModified) {
mtime = new Date(_lastModified);
}
return {
status: res.status,
mtime
}
mtime,
};
},
async setItem(key, value) {
await $fetch(r(key), { method: 'PUT', body: stringify(value) })
await $fetch(r(key), { method: "PUT", body: stringify(value) });
},
async removeItem (key) {
await $fetch(r(key), { method: 'DELETE' })
async removeItem(key) {
await $fetch(r(key), { method: "DELETE" });
},
async getKeys() {
const value = await $fetch(r(''))
return Array.isArray(value) ? value : []
const value = await $fetch(r(""));
return Array.isArray(value) ? value : [];
},
clear() {
// Not supported
}
}
})
},
};
});

View File

@@ -1,69 +1,68 @@
import { defineDriver } from './utils'
import { defineDriver } from "./utils";
export interface LocalStorageOptions {
base?: string
window?: typeof window
localStorage?: typeof window.localStorage
base?: string;
window?: typeof window;
localStorage?: typeof window.localStorage;
}
export default defineDriver((opts: LocalStorageOptions = {}) => {
if (!opts.window) {
opts.window = typeof window !== 'undefined' ? window : undefined
opts.window = typeof window !== "undefined" ? window : undefined;
}
if (!opts.localStorage) {
opts.localStorage = opts.window?.localStorage
opts.localStorage = opts.window?.localStorage;
}
if (!opts.localStorage) {
throw new Error('localStorage not available')
throw new Error("localStorage not available");
}
const r = (key: string) => (opts.base ? opts.base + ':' : '') + key
const r = (key: string) => (opts.base ? opts.base + ":" : "") + key;
let _storageListener: (ev: StorageEvent) => void
let _storageListener: (ev: StorageEvent) => void;
return {
hasItem (key) {
return Object.prototype.hasOwnProperty.call(opts.localStorage!, r(key))
hasItem(key) {
return Object.prototype.hasOwnProperty.call(opts.localStorage!, r(key));
},
getItem (key) {
return opts.localStorage!.getItem(r(key))
getItem(key) {
return opts.localStorage!.getItem(r(key));
},
setItem (key, value) {
return opts.localStorage!.setItem(r(key), value)
setItem(key, value) {
return opts.localStorage!.setItem(r(key), value);
},
removeItem (key) {
return opts.localStorage!.removeItem(r(key))
removeItem(key) {
return opts.localStorage!.removeItem(r(key));
},
getKeys () {
return Object.keys(opts.localStorage!)
getKeys() {
return Object.keys(opts.localStorage!);
},
clear() {
if (!opts.base) {
opts.localStorage!.clear()
opts.localStorage!.clear();
} else {
for (const key of Object.keys(opts.localStorage!)) {
opts.localStorage?.removeItem(key)
opts.localStorage?.removeItem(key);
}
}
if (opts.window && _storageListener) {
opts.window.removeEventListener('storage', _storageListener)
opts.window.removeEventListener("storage", _storageListener);
}
},
watch(callback) {
if (!opts.window) {
return
return;
}
_storageListener = (ev: StorageEvent) => {
if (ev.key) {
callback(ev.newValue ? 'update' : 'remove', ev.key)
callback(ev.newValue ? "update" : "remove", ev.key);
}
}
opts.window.addEventListener('storage', _storageListener)
};
opts.window.addEventListener("storage", _storageListener);
return () => {
opts.window.removeEventListener('storage', _storageListener)
_storageListener = undefined
}
}
}
})
opts.window.removeEventListener("storage", _storageListener);
_storageListener = undefined;
};
},
};
});

View File

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

View File

@@ -1,69 +1,75 @@
import { defineDriver } from './utils'
import type { Driver } from '../types'
import { normalizeKey } from './utils'
import { defineDriver } from "./utils";
import type { Driver } from "../types";
import { normalizeKey } from "./utils";
export interface OverlayStorageOptions {
layers: Driver[]
layers: Driver[];
}
const OVERLAY_REMOVED = '__OVERLAY_REMOVED__'
const OVERLAY_REMOVED = "__OVERLAY_REMOVED__";
export default defineDriver((options: OverlayStorageOptions) => {
return {
async hasItem (key) {
async hasItem(key) {
for (const layer of options.layers) {
if (await layer.hasItem(key)) {
if (layer === options.layers[0]) {
if (await options.layers[0]?.getItem(key) === OVERLAY_REMOVED) {
return false
if ((await options.layers[0]?.getItem(key)) === OVERLAY_REMOVED) {
return false;
}
}
return true
return true;
}
}
return false
return false;
},
async getItem (key) {
async getItem(key) {
for (const layer of options.layers) {
const value = await layer.getItem(key)
const value = await layer.getItem(key);
if (value === OVERLAY_REMOVED) {
return null
return null;
}
if (value !== null) {
return value
return value;
}
}
return null
return null;
},
// TODO: Support native meta
// async getMeta (key) {},
async setItem(key, value) {
await options.layers[0]?.setItem(key, value)
await options.layers[0]?.setItem(key, value);
},
async removeItem (key) {
await options.layers[0]?.setItem(key, OVERLAY_REMOVED)
async removeItem(key) {
await options.layers[0]?.setItem(key, OVERLAY_REMOVED);
},
async getKeys(base) {
const allKeys = await Promise.all(options.layers.map(async layer => {
const keys = await layer.getKeys(base)
return keys.map(key => normalizeKey(key))
}))
const uniqueKeys = Array.from(new Set(allKeys.flat()))
const existingKeys = await Promise.all(uniqueKeys.map(async key => {
if (await options.layers[0]?.getItem(key) === OVERLAY_REMOVED) {
return false
}
return key
}))
return existingKeys.filter(Boolean) as string[]
const allKeys = await Promise.all(
options.layers.map(async (layer) => {
const keys = await layer.getKeys(base);
return keys.map((key) => normalizeKey(key));
})
);
const uniqueKeys = Array.from(new Set(allKeys.flat()));
const existingKeys = await Promise.all(
uniqueKeys.map(async (key) => {
if ((await options.layers[0]?.getItem(key)) === OVERLAY_REMOVED) {
return false;
}
return key;
})
);
return existingKeys.filter(Boolean) as string[];
},
async dispose() {
// TODO: Graceful error handling
await Promise.all(options.layers.map(async layer => {
if (layer.dispose) {
await layer.dispose()
}
}))
}
}
})
await Promise.all(
options.layers.map(async (layer) => {
if (layer.dispose) {
await layer.dispose();
}
})
);
},
};
});

View File

@@ -1,41 +1,43 @@
import { defineDriver } from './utils'
import Redis, { RedisOptions as _RedisOptions } from 'ioredis'
import { defineDriver } from "./utils";
import Redis, { RedisOptions as _RedisOptions } from "ioredis";
export interface RedisOptions extends _RedisOptions {
base: string
url: string
base: string;
url: string;
}
export default defineDriver<RedisOptions>((_opts) => {
const opts = { lazyConnect: true, ..._opts }
const redis = opts.url ? new Redis(opts?.url, opts) : new Redis(opts)
const opts = { lazyConnect: true, ..._opts };
const redis = opts.url ? new Redis(opts?.url, opts) : new Redis(opts);
let base = opts?.base || ''
if (base && !base.endsWith(':')) { base += ':' }
const r = (key: string) => base + key
let base = opts?.base || "";
if (base && !base.endsWith(":")) {
base += ":";
}
const r = (key: string) => base + key;
return {
hasItem (key) {
return redis.exists(r(key)).then(Boolean)
hasItem(key) {
return redis.exists(r(key)).then(Boolean);
},
getItem (key) {
return redis.get(r(key))
getItem(key) {
return redis.get(r(key));
},
setItem(key, value) {
return redis.set(r(key), value).then(() => {})
return redis.set(r(key), value).then(() => {});
},
removeItem (key) {
return redis.del(r(key)).then(() => {})
removeItem(key) {
return redis.del(r(key)).then(() => {});
},
getKeys() {
return redis.keys(r('*'))
return redis.keys(r("*"));
},
async clear() {
const keys = await redis.keys(r('*'))
return redis.del(keys.map(key => r(key))).then(() => {})
const keys = await redis.keys(r("*"));
return redis.del(keys.map((key) => r(key))).then(() => {});
},
dispose() {
return redis.disconnect()
}
}
})
return redis.disconnect();
},
};
});

View File

@@ -1,21 +1,25 @@
import type { Driver } from '../../types'
import type { Driver } from "../../types";
type DriverFactory<T> = (opts?: T) => Driver
type DriverFactory<T> = (opts?: T) => Driver;
export function defineDriver<T = any>(factory: DriverFactory<T>): DriverFactory<T> {
return factory
export function defineDriver<T = any>(
factory: DriverFactory<T>
): DriverFactory<T> {
return factory;
}
export function isPrimitive (arg: any) {
const type = typeof arg
return arg === null || (type !== 'object' && type !== 'function')
export function isPrimitive(arg: any) {
const type = typeof arg;
return arg === null || (type !== "object" && type !== "function");
}
export function stringify (arg: any) {
return isPrimitive(arg) ? (arg + '') : JSON.stringify(arg)
export function stringify(arg: any) {
return isPrimitive(arg) ? arg + "" : JSON.stringify(arg);
}
export function normalizeKey (key: string | undefined): string {
if (!key) { return '' }
return key.replace(/[/\\]/g, ':').replace(/^:|:$/g, '')
export function normalizeKey(key: string | undefined): string {
if (!key) {
return "";
}
return key.replace(/[/\\]/g, ":").replace(/^:|:$/g, "");
}

View File

@@ -1,69 +1,81 @@
import { Dirent, existsSync, promises as fsPromises } from 'fs'
import { resolve, dirname } from 'path'
import { Dirent, existsSync, promises as fsPromises } from "fs";
import { resolve, dirname } from "path";
function ignoreNotfound (err: any) {
return (err.code === 'ENOENT' || err.code === 'EISDIR') ? null : err
function ignoreNotfound(err: any) {
return err.code === "ENOENT" || err.code === "EISDIR" ? null : err;
}
function ignoreExists (err: any) {
return (err.code === 'EEXIST') ? null : err
function ignoreExists(err: any) {
return err.code === "EEXIST" ? null : err;
}
export async function writeFile (path: string, data: string) {
await ensuredir(dirname(path))
return fsPromises.writeFile(path, data, 'utf8')
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(ignoreNotfound)
export function readFile(path: string) {
return fsPromises.readFile(path, "utf8").catch(ignoreNotfound);
}
export function stat (path: string) {
return fsPromises.stat(path).catch(ignoreNotfound)
export function stat(path: string) {
return fsPromises.stat(path).catch(ignoreNotfound);
}
export function unlink (path: string) {
return fsPromises.unlink(path).catch(ignoreNotfound)
export function unlink(path: string) {
return fsPromises.unlink(path).catch(ignoreNotfound);
}
export function readdir (dir: string): Promise<Dirent[]> {
return fsPromises.readdir(dir, { withFileTypes: true }).catch(ignoreNotfound).then(r => r || [])
export function readdir(dir: string): Promise<Dirent[]> {
return fsPromises
.readdir(dir, { withFileTypes: true })
.catch(ignoreNotfound)
.then((r) => r || []);
}
export async function ensuredir (dir: string) {
if (existsSync(dir)) { return }
await ensuredir(dirname(dir)).catch(ignoreExists)
await fsPromises.mkdir(dir).catch(ignoreExists)
}
export async function readdirRecursive(dir: string, ignore?: (p: string) => boolean) {
if (ignore && ignore(dir)) {
return []
export async function ensuredir(dir: string) {
if (existsSync(dir)) {
return;
}
const entries: Dirent[] = await readdir(dir)
const files: string[] = []
await Promise.all(entries.map(async (entry) => {
const entryPath = resolve(dir, entry.name)
if (entry.isDirectory()) {
const dirFiles = await readdirRecursive(entryPath, ignore)
files.push(...dirFiles.map(f => entry.name + '/' + f))
} else {
if (ignore && !ignore(entry.name)) {
files.push(entry.name)
}
}
}))
return files
await ensuredir(dirname(dir)).catch(ignoreExists);
await fsPromises.mkdir(dir).catch(ignoreExists);
}
export async function rmRecursive (dir: string) {
const entries = await readdir(dir)
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)
}
}))
export async function readdirRecursive(
dir: string,
ignore?: (p: string) => boolean
) {
if (ignore && ignore(dir)) {
return [];
}
const entries: Dirent[] = await readdir(dir);
const files: string[] = [];
await Promise.all(
entries.map(async (entry) => {
const entryPath = resolve(dir, entry.name);
if (entry.isDirectory()) {
const dirFiles = await readdirRecursive(entryPath, ignore);
files.push(...dirFiles.map((f) => entry.name + "/" + f));
} else {
if (ignore && !ignore(entry.name)) {
files.push(entry.name);
}
}
})
);
return files;
}
export async function rmRecursive(dir: string) {
const entries = await readdir(dir);
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);
}
})
);
}

View File

@@ -15,7 +15,7 @@ export const builtinDrivers = {
localstorage: "unstorage/drivers/localstorage",
memory: "unstorage/drivers/memory",
overlay: "unstorage/drivers/overlay",
redis: "unstorage/drivers/redis"
redis: "unstorage/drivers/redis",
};
export type BuiltinDriverName = keyof typeof builtinDrivers
export type BuiltinDriverName = keyof typeof builtinDrivers;

View File

@@ -1,56 +1,71 @@
import type { RequestListener } from "node:http";
import { createApp, createError, readBody, eventHandler, toNodeListener } from "h3";
import {
createApp,
createError,
readBody,
eventHandler,
toNodeListener,
} from "h3";
import { Storage } from "./types";
import { stringify } from "./_utils";
export interface StorageServerOptions {}
export interface StorageServer {
handle: RequestListener
handle: RequestListener;
}
export function createStorageServer (storage: Storage, _options: StorageServerOptions = {}): StorageServer {
export function createStorageServer(
storage: Storage,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_options: StorageServerOptions = {}
): StorageServer {
const app = createApp();
app.use(eventHandler(async (event) => {
// GET => getItem
if (event.req.method === "GET") {
const value = await storage.getItem(event.req.url!);
if (!value) {
const keys = await storage.getKeys(event.req.url);
return keys.map(key => key.replace(/:/g, "/"));
}
return stringify(value);
}
// HEAD => hasItem + meta (mtime)
if (event.req.method === "HEAD") {
const _hasItem = await storage.hasItem(event.req.url!);
event.res.statusCode = _hasItem ? 200 : 404;
if (_hasItem) {
const meta = await storage.getMeta(event.req.url!);
if (meta.mtime) {
event.res.setHeader("Last-Modified", new Date(meta.mtime).toUTCString());
app.use(
eventHandler(async (event) => {
// GET => getItem
if (event.req.method === "GET") {
const value = await storage.getItem(event.req.url!);
if (!value) {
const keys = await storage.getKeys(event.req.url);
return keys.map((key) => key.replace(/:/g, "/"));
}
return stringify(value);
}
return "";
}
// PUT => setItem
if (event.req.method === "PUT") {
const value = await readBody(event);
await storage.setItem(event.req.url!, value);
return "OK";
}
// DELETE => removeItem
if (event.req.method === "DELETE") {
await storage.removeItem(event.req.url!);
return "OK";
}
throw createError({
statusCode: 405,
statusMessage: "Method Not Allowed"
});
}));
// HEAD => hasItem + meta (mtime)
if (event.req.method === "HEAD") {
const _hasItem = await storage.hasItem(event.req.url!);
event.res.statusCode = _hasItem ? 200 : 404;
if (_hasItem) {
const meta = await storage.getMeta(event.req.url!);
if (meta.mtime) {
event.res.setHeader(
"Last-Modified",
new Date(meta.mtime).toUTCString()
);
}
}
return "";
}
// PUT => setItem
if (event.req.method === "PUT") {
const value = await readBody(event);
await storage.setItem(event.req.url!, value);
return "OK";
}
// DELETE => removeItem
if (event.req.method === "DELETE") {
await storage.removeItem(event.req.url!);
return "OK";
}
throw createError({
statusCode: 405,
statusMessage: "Method Not Allowed",
});
})
);
return {
handle: toNodeListener(app)
handle: toNodeListener(app),
};
}

View File

@@ -1,28 +1,35 @@
import destr from "destr";
import type { Storage, Driver, WatchCallback, Unwatch, StorageValue } from "./types";
import type {
Storage,
Driver,
WatchCallback,
Unwatch,
StorageValue,
WatchEvent,
} from "./types";
import memory from "./drivers/memory";
import { asyncCall, stringify } from "./_utils";
import { normalizeKey, normalizeBaseKey } from "./utils";
interface StorageCTX {
mounts: Record<string, Driver>
mountpoints: string[]
watching: boolean
unwatch: Record<string, Unwatch>
watchListeners: Function[]
mounts: Record<string, Driver>;
mountpoints: string[];
watching: boolean;
unwatch: Record<string, Unwatch>;
watchListeners: ((event: WatchEvent, key: string) => void)[];
}
export interface CreateStorageOptions {
driver?: Driver
driver?: Driver;
}
export function createStorage (options: CreateStorageOptions = {}): Storage {
export function createStorage(options: CreateStorageOptions = {}): Storage {
const context: StorageCTX = {
mounts: { "": options.driver || memory() },
mountpoints: [""],
watching: false,
watchListeners: [],
unwatch: {}
unwatch: {},
};
const getMount = (key: string) => {
@@ -30,28 +37,37 @@ export function createStorage (options: CreateStorageOptions = {}): Storage {
if (key.startsWith(base)) {
return {
relativeKey: key.slice(base.length),
driver: context.mounts[base]
driver: context.mounts[base],
};
}
}
return {
relativeKey: key,
driver: context.mounts[""]
driver: context.mounts[""],
};
};
const getMounts = (base: string, includeParent: boolean) => {
return context.mountpoints
.filter(mountpoint => (mountpoint.startsWith(base)) || (includeParent && base!.startsWith(mountpoint)))
.map(mountpoint => ({
relativeBase: base.length > mountpoint.length ? base!.slice(mountpoint.length) : undefined,
.filter(
(mountpoint) =>
mountpoint.startsWith(base) ||
(includeParent && base!.startsWith(mountpoint))
)
.map((mountpoint) => ({
relativeBase:
base.length > mountpoint.length
? base!.slice(mountpoint.length)
: undefined,
mountpoint,
driver: context.mounts[mountpoint]
driver: context.mounts[mountpoint],
}));
};
const onChange: WatchCallback = (event, key) => {
if (!context.watching) { return; }
if (!context.watching) {
return;
}
key = normalizeKey(key);
for (const listener of context.watchListeners) {
listener(event, key);
@@ -59,15 +75,23 @@ export function createStorage (options: CreateStorageOptions = {}): Storage {
};
const startWatch = async () => {
if (context.watching) { return; }
if (context.watching) {
return;
}
context.watching = true;
for (const mountpoint in context.mounts) {
context.unwatch[mountpoint] = await watch(context.mounts[mountpoint], onChange, mountpoint);
context.unwatch[mountpoint] = await watch(
context.mounts[mountpoint],
onChange,
mountpoint
);
}
};
const stopWatch = async () => {
if (!context.watching) { return; }
if (!context.watching) {
return;
}
for (const mountpoint in context.unwatch) {
await context.unwatch[mountpoint]();
}
@@ -77,17 +101,19 @@ export function createStorage (options: CreateStorageOptions = {}): Storage {
const storage: Storage = {
// Item
hasItem (key) {
hasItem(key) {
key = normalizeKey(key);
const { relativeKey, driver } = getMount(key);
return asyncCall(driver.hasItem, relativeKey);
},
getItem (key) {
getItem(key) {
key = normalizeKey(key);
const { relativeKey, driver } = getMount(key);
return asyncCall(driver.getItem, relativeKey).then(value => destr(value));
return asyncCall(driver.getItem, relativeKey).then((value) =>
destr(value)
);
},
async setItem (key, value) {
async setItem(key, value) {
if (value === undefined) {
return storage.removeItem(key);
}
@@ -101,7 +127,7 @@ export function createStorage (options: CreateStorageOptions = {}): Storage {
onChange("update", key);
}
},
async removeItem (key, removeMeta = true) {
async removeItem(key, removeMeta = true) {
key = normalizeKey(key);
const { relativeKey, driver } = getMount(key);
if (!driver.removeItem) {
@@ -116,7 +142,7 @@ export function createStorage (options: CreateStorageOptions = {}): Storage {
}
},
// Meta
async getMeta (key, nativeMetaOnly) {
async getMeta(key, nativeMetaOnly) {
key = normalizeKey(key);
const { relativeKey, driver } = getMount(key);
const meta = Object.create(null);
@@ -124,80 +150,95 @@ export function createStorage (options: CreateStorageOptions = {}): Storage {
Object.assign(meta, await asyncCall(driver.getMeta, relativeKey));
}
if (!nativeMetaOnly) {
const value = await asyncCall(driver.getItem, relativeKey + "$").then(value_ => destr(value_));
const value = await asyncCall(driver.getItem, relativeKey + "$").then(
(value_) => destr(value_)
);
if (value && typeof value === "object") {
// TODO: Support date by destr?
if (typeof value.atime === "string") { value.atime = new Date(value.atime); }
if (typeof value.mtime === "string") { value.mtime = new Date(value.mtime); }
if (typeof value.atime === "string") {
value.atime = new Date(value.atime);
}
if (typeof value.mtime === "string") {
value.mtime = new Date(value.mtime);
}
Object.assign(meta, value);
}
}
return meta;
},
setMeta (key: string, value: any) {
setMeta(key: string, value: any) {
return this.setItem(key + "$", value);
},
removeMeta (key: string) {
removeMeta(key: string) {
return this.removeItem(key + "$");
},
// Keys
async getKeys (base) {
async getKeys(base) {
base = normalizeBaseKey(base);
const mounts = getMounts(base, true);
let maskedMounts = [];
const allKeys = [];
for (const mount of mounts) {
const rawKeys = await asyncCall(mount.driver.getKeys, mount.relativeBase);
const rawKeys = await asyncCall(
mount.driver.getKeys,
mount.relativeBase
);
const keys = rawKeys
.map(key => mount.mountpoint + normalizeKey(key))
.filter(key => !maskedMounts.some(p => key.startsWith(p)));
.map((key) => mount.mountpoint + normalizeKey(key))
.filter((key) => !maskedMounts.some((p) => key.startsWith(p)));
allKeys.push(...keys);
// When /mnt/foo is processed, any key in /mnt with /mnt/foo prefix should be masked
// Using filter to improve performance. /mnt mask already covers /mnt/foo
maskedMounts = [
mount.mountpoint,
...maskedMounts.filter(p => !p.startsWith(mount.mountpoint))
...maskedMounts.filter((p) => !p.startsWith(mount.mountpoint)),
];
}
return base
? allKeys.filter(key => key.startsWith(base!) && !key.endsWith("$"))
: allKeys.filter(key => !key.endsWith("$"));
? allKeys.filter((key) => key.startsWith(base!) && !key.endsWith("$"))
: allKeys.filter((key) => !key.endsWith("$"));
},
// Utils
async clear (base) {
async clear(base) {
base = normalizeBaseKey(base);
await Promise.all(getMounts(base, false).map(async (m) => {
if (m.driver.clear) {
return asyncCall(m.driver.clear);
}
// Fallback to remove all keys if clear not implemented
if (m.driver.removeItem) {
const keys = await m.driver.getKeys();
return Promise.all(keys.map(key => m.driver.removeItem!(key)));
}
// Readonly
}));
await Promise.all(
getMounts(base, false).map(async (m) => {
if (m.driver.clear) {
return asyncCall(m.driver.clear);
}
// Fallback to remove all keys if clear not implemented
if (m.driver.removeItem) {
const keys = await m.driver.getKeys();
return Promise.all(keys.map((key) => m.driver.removeItem!(key)));
}
// Readonly
})
);
},
async dispose () {
await Promise.all(Object.values(context.mounts).map(driver => dispose(driver)));
async dispose() {
await Promise.all(
Object.values(context.mounts).map((driver) => dispose(driver))
);
},
async watch (callback) {
async watch(callback) {
await startWatch();
context.watchListeners.push(callback);
return async () => {
context.watchListeners = context.watchListeners.filter(listener => listener !== callback);
context.watchListeners = context.watchListeners.filter(
(listener) => listener !== callback
);
if (context.watchListeners.length === 0) {
await stopWatch();
}
};
},
async unwatch () {
async unwatch() {
context.watchListeners = [];
await stopWatch();
},
// Mount
mount (base, driver) {
mount(base, driver) {
base = normalizeBaseKey(base);
if (base && context.mounts[base]) {
throw new Error(`already mounted at ${base}`);
@@ -216,7 +257,7 @@ export function createStorage (options: CreateStorageOptions = {}): Storage {
}
return storage;
},
async unmount (base: string, _dispose = true) {
async unmount(base: string, _dispose = true) {
base = normalizeBaseKey(base);
if (!base /* root */ || !context.mounts[base]) {
return;
@@ -228,36 +269,49 @@ export function createStorage (options: CreateStorageOptions = {}): Storage {
if (_dispose) {
await dispose(context.mounts[base]);
}
context.mountpoints = context.mountpoints.filter(key => key !== base);
context.mountpoints = context.mountpoints.filter((key) => key !== base);
delete context.mounts[base];
}
},
};
return storage;
}
export type Snapshot<T=string> = Record<string, T>
export type Snapshot<T = string> = Record<string, T>;
export async function snapshot (storage: Storage, base: string): Promise<Snapshot<string>> {
export async function snapshot(
storage: Storage,
base: string
): Promise<Snapshot<string>> {
base = normalizeBaseKey(base);
const keys = await storage.getKeys(base);
const snapshot: any = {};
await Promise.all(keys.map(async (key) => {
snapshot[key.slice(base.length)] = await storage.getItem(key);
}));
await Promise.all(
keys.map(async (key) => {
snapshot[key.slice(base.length)] = await storage.getItem(key);
})
);
return snapshot;
}
export async function restoreSnapshot (driver: Storage, snapshot: Snapshot<StorageValue>, base: string = "") {
export async function restoreSnapshot(
driver: Storage,
snapshot: Snapshot<StorageValue>,
base = ""
) {
base = normalizeBaseKey(base);
await Promise.all(Object.entries(snapshot).map(e => driver.setItem(base + e[0], e[1])));
await Promise.all(
Object.entries(snapshot).map((e) => driver.setItem(base + e[0], e[1]))
);
}
function watch (driver: Driver, onChange: WatchCallback, base: string) {
return driver.watch ? driver.watch((event, key) => onChange(event, base + key)) : () => {};
function watch(driver: Driver, onChange: WatchCallback, base: string) {
return driver.watch
? driver.watch((event, key) => onChange(event, base + key))
: () => {};
}
async function dispose (driver: Driver) {
async function dispose(driver: Driver) {
if (typeof driver.dispose === "function") {
await asyncCall(driver.dispose);
}

View File

@@ -1,47 +1,47 @@
export type StorageValue = null | string | String | number | Number | boolean | Boolean | object
export type WatchEvent = "update" | "remove"
export type WatchCallback = (event: WatchEvent, key: string) => any
export type StorageValue = null | string | number | boolean | object;
export type WatchEvent = "update" | "remove";
export type WatchCallback = (event: WatchEvent, key: string) => any;
type MaybePromise<T> = T | Promise<T>
type MaybePromise<T> = T | Promise<T>;
export type Unwatch = () => MaybePromise<void>
export type Unwatch = () => MaybePromise<void>;
export interface StorageMeta {
atime?: Date
mtime?: Date
[key: string]: StorageValue | Date | undefined
atime?: Date;
mtime?: Date;
[key: string]: StorageValue | Date | undefined;
}
export interface Driver {
hasItem: (key: string) => MaybePromise<boolean>
getItem: (key: string) => StorageValue
setItem?: (key: string, value: string) => MaybePromise<void>
removeItem?: (key: string) => MaybePromise<void>
getMeta?: (key: string) => MaybePromise<StorageMeta>
getKeys: (base?: string) => MaybePromise<string[]>
clear?: () => MaybePromise<void>
dispose?: () => MaybePromise<void>
watch?: (callback: WatchCallback) => MaybePromise<Unwatch>
hasItem: (key: string) => MaybePromise<boolean>;
getItem: (key: string) => StorageValue;
setItem?: (key: string, value: string) => MaybePromise<void>;
removeItem?: (key: string) => MaybePromise<void>;
getMeta?: (key: string) => MaybePromise<StorageMeta>;
getKeys: (base?: string) => MaybePromise<string[]>;
clear?: () => MaybePromise<void>;
dispose?: () => MaybePromise<void>;
watch?: (callback: WatchCallback) => MaybePromise<Unwatch>;
}
export interface Storage {
// Item
hasItem: (key: string) => Promise<boolean>
getItem: (key: string) => Promise<StorageValue>
setItem: (key: string, value: StorageValue) => Promise<void>
removeItem: (key: string, removeMeta?: boolean) => Promise<void>
hasItem: (key: string) => Promise<boolean>;
getItem: (key: string) => Promise<StorageValue>;
setItem: (key: string, value: StorageValue) => Promise<void>;
removeItem: (key: string, removeMeta?: boolean) => Promise<void>;
// Meta
getMeta: (key: string, nativeMetaOnly?: true) => MaybePromise<StorageMeta>
setMeta: (key: string, value: StorageMeta) => Promise<void>
removeMeta: (key: string) => Promise<void>
getMeta: (key: string, nativeMetaOnly?: true) => MaybePromise<StorageMeta>;
setMeta: (key: string, value: StorageMeta) => Promise<void>;
removeMeta: (key: string) => Promise<void>;
// Keys
getKeys: (base?: string) => Promise<string[]>
getKeys: (base?: string) => Promise<string[]>;
// Utils
clear: (base?: string) => Promise<void>
dispose: () => Promise<void>
watch: (callback: WatchCallback) => Promise<Unwatch>
unwatch: () => Promise<void>
clear: (base?: string) => Promise<void>;
dispose: () => Promise<void>;
watch: (callback: WatchCallback) => Promise<Unwatch>;
unwatch: () => Promise<void>;
// Mount
mount: (base: string, driver: Driver) => Storage
unmount: (base: string, dispose?: boolean) => Promise<void>
mount: (base: string, driver: Driver) => Storage;
unmount: (base: string, dispose?: boolean) => Promise<void>;
}

View File

@@ -1,6 +1,6 @@
import type { Storage } from "./types";
type StorageKeys = Array<keyof Storage>
type StorageKeys = Array<keyof Storage>;
const storageKeyProperties: StorageKeys = [
"hasItem",
@@ -13,38 +13,42 @@ const storageKeyProperties: StorageKeys = [
"getKeys",
"clear",
"mount",
"unmount"
"unmount",
];
export function prefixStorage (storage: Storage, base: string) {
export function prefixStorage(storage: Storage, base: string) {
base = normalizeBaseKey(base);
if (!base) {
return storage;
}
const nsStorage: Storage = { ...storage };
for (const property of storageKeyProperties) {
// @ts-ignore Better types?
nsStorage[property] = (key: string = "", ...arguments_) => storage[property](base + key, ...arguments_);
// @ts-ignore
nsStorage[property] = (key = "", ...args) =>
// @ts-ignore
storage[property](base + key, ...args);
}
nsStorage.getKeys = (key: string = "", ...arguments_) =>
nsStorage.getKeys = (key = "", ...arguments_) =>
storage
.getKeys(base + key, ...arguments_)
// Remove Prefix
.then(keys => keys.map(key => key.slice(base.length)));
.then((keys) => keys.map((key) => key.slice(base.length)));
return nsStorage;
}
export function normalizeKey (key?: string) {
if (!key) { return ""; }
export function normalizeKey(key?: string) {
if (!key) {
return "";
}
return key.replace(/[/\\]/g, ":").replace(/:+/g, ":").replace(/^:|:$/g, "");
}
export function joinKeys (...keys: string[]) {
export function joinKeys(...keys: string[]) {
return normalizeKey(keys.join(":"));
}
export function normalizeBaseKey (base?: string) {
export function normalizeBaseKey(base?: string) {
base = normalizeKey(base);
return base ? (base + ":") : "";
return base ? base + ":" : "";
}

View File

@@ -1,22 +1,34 @@
/// <reference types="@cloudflare/workers-types" />
import { describe } from 'vitest'
import { createStorage } from '../../src'
import CloudflareKVBinding from '../../src/drivers/cloudflare-kv-binding'
import { testDriver } from './utils'
import { describe } from "vitest";
import { createStorage } from "../../src";
import CloudflareKVBinding from "../../src/drivers/cloudflare-kv-binding";
import { testDriver } from "./utils";
const mockStorage = createStorage()
const mockStorage = createStorage();
// https://developers.cloudflare.com/workers/runtime-apis/kv/
const mockBinding: KVNamespace = {
get(key) { return mockStorage.getItem(key) as any },
getWithMetadata(key: string) {return mockStorage.getItem(key) as any },
put(key, value) { return mockStorage.setItem(key, value) as any },
delete(key) { return mockStorage.removeItem(key) as any },
list(opts) { return mockStorage.getKeys(opts?.prefix || undefined).then(keys => ({ keys: keys.map(name => ({ name })) })) as any },
}
get(key) {
return mockStorage.getItem(key) as any;
},
getWithMetadata(key: string) {
return mockStorage.getItem(key) as any;
},
put(key, value) {
return mockStorage.setItem(key, value) as any;
},
delete(key) {
return mockStorage.removeItem(key) as any;
},
list(opts) {
return mockStorage
.getKeys(opts?.prefix || undefined)
.then((keys) => ({ keys: keys.map((name) => ({ name })) })) as any;
},
};
describe('drivers: cloudflare-kv', () => {
describe("drivers: cloudflare-kv", () => {
testDriver({
driver: CloudflareKVBinding({ binding: mockBinding })
})
})
driver: CloudflareKVBinding({ binding: mockBinding }),
});
});

View File

@@ -1,50 +1,54 @@
import { afterAll, beforeAll, describe } from 'vitest'
import driver, { KVHTTPOptions } from '../../src/drivers/cloudflare-kv-http'
import { testDriver } from './utils'
import { rest } from 'msw'
import { setupServer } from 'msw/node'
import { afterAll, beforeAll, describe } from "vitest";
import driver, { KVHTTPOptions } from "../../src/drivers/cloudflare-kv-http";
import { testDriver } from "./utils";
import { rest } from "msw";
import { setupServer } from "msw/node";
const baseURL =
'https://api.cloudflare.com/client/v4/accounts/:accountId/storage/kv/namespaces/:namespaceId'
"https://api.cloudflare.com/client/v4/accounts/:accountId/storage/kv/namespaces/:namespaceId";
const store: Record<string, any> = {}
const store: Record<string, any> = {};
const server = setupServer(
rest.get(`${baseURL}/values/:key`, (req, res, ctx) => {
const key = req.params.key as string
const key = req.params.key as string;
if (!(key in store)) {
return res(ctx.status(404), ctx.json(null))
return res(ctx.status(404), ctx.json(null));
}
return res(ctx.status(200), ctx.set('content-type', 'application/octet-stream'), ctx.json(store[key]))
return res(
ctx.status(200),
ctx.set("content-type", "application/octet-stream"),
ctx.json(store[key])
);
}),
rest.get(`${baseURL}/metadata/:key`, (req, res, ctx) => {
const key = req.params.key as string
const key = req.params.key as string;
if (!(key in store)) {
return res(ctx.status(404), ctx.json({ success: false }))
return res(ctx.status(404), ctx.json({ success: false }));
}
return res(ctx.status(200), ctx.json({ success: true }))
return res(ctx.status(200), ctx.json({ success: true }));
}),
rest.put(`${baseURL}/values/:key`, (req, res, ctx) => {
const key = req.params.key as string
store[key] = req.body
return res(ctx.status(204), ctx.json(null))
const key = req.params.key as string;
store[key] = req.body;
return res(ctx.status(204), ctx.json(null));
}),
rest.delete(`${baseURL}/values/:key`, (req, res, ctx) => {
const key = req.params.key as string
delete store[key]
return res(ctx.status(204))
const key = req.params.key as string;
delete store[key];
return res(ctx.status(204));
}),
rest.get(`${baseURL}/keys`, (req, res, ctx) => {
const prefix = req.url.searchParams.get('prefix') || ''
let keys = Object.keys(store)
if (req.url.searchParams.has('prefix')) {
keys = keys.filter((key) => key.startsWith(prefix))
const prefix = req.url.searchParams.get("prefix") || "";
let keys = Object.keys(store);
if (req.url.searchParams.has("prefix")) {
keys = keys.filter((key) => key.startsWith(prefix));
}
const result = keys.map((key) => ({ name: key }))
const result = keys.map((key) => ({ name: key }));
const data = {
result,
@@ -53,37 +57,37 @@ const server = setupServer(
messages: [],
result_info: {
count: keys.length,
cursor: '',
cursor: "",
},
}
};
return res(ctx.status(200), ctx.json(data))
return res(ctx.status(200), ctx.json(data));
}),
rest.delete(`${baseURL}/bulk`, (_req, res, ctx) => {
Object.keys(store).forEach((key) => delete store[key])
return res(ctx.status(204))
Object.keys(store).forEach((key) => delete store[key]);
return res(ctx.status(204));
})
)
);
const mockOptions: KVHTTPOptions = {
apiToken: 'api-token',
accountId: 'account-id',
namespaceId: 'namespace-id',
}
apiToken: "api-token",
accountId: "account-id",
namespaceId: "namespace-id",
};
describe('drivers: cloudflare-kv-http', () => {
describe("drivers: cloudflare-kv-http", () => {
beforeAll(() => {
// Establish requests interception layer before all tests.
server.listen()
})
server.listen();
});
afterAll(() => {
// Clean up after all tests are done, preventing this
// interception layer from affecting irrelevant tests.
server.close()
})
server.close();
});
testDriver({
driver: driver(mockOptions),
})
})
});
});

View File

@@ -1,49 +1,43 @@
import { describe, it, expect, vi } from 'vitest'
import { resolve } from 'path'
import { readFile, writeFile } from '../../src/drivers/utils/node-fs'
import { testDriver } from './utils'
import driver from '../../src/drivers/fs'
import { describe, it, expect, vi } from "vitest";
import { resolve } from "path";
import { readFile, writeFile } from "../../src/drivers/utils/node-fs";
import { testDriver } from "./utils";
import driver from "../../src/drivers/fs";
describe('drivers: fs', () => {
const dir = resolve(__dirname, 'tmp')
describe("drivers: fs", () => {
const dir = resolve(__dirname, "tmp");
testDriver({
driver: driver({ base: dir }),
additionalTests(ctx) {
it('check filesystem', async () => {
expect(await readFile(resolve(dir, 's1/a'))).toBe('test_data')
})
it('native meta', async () => {
const meta = await ctx.storage.getMeta('/s1/a')
expect(meta.atime?.constructor.name).toBe('Date')
expect(meta.mtime?.constructor.name).toBe('Date')
expect(meta.size).toBeGreaterThan(0)
})
it('watch filesystem', async () => {
const watcher = vi.fn()
await ctx.storage.watch(watcher)
await writeFile(resolve(dir, 's1/random_file'), 'random')
await new Promise(resolve => setTimeout(resolve, 500))
expect(watcher).toHaveBeenCalledWith('update', 's1:random_file')
})
it("check filesystem", async () => {
expect(await readFile(resolve(dir, "s1/a"))).toBe("test_data");
});
it("native meta", async () => {
const meta = await ctx.storage.getMeta("/s1/a");
expect(meta.atime?.constructor.name).toBe("Date");
expect(meta.mtime?.constructor.name).toBe("Date");
expect(meta.size).toBeGreaterThan(0);
});
it("watch filesystem", async () => {
const watcher = vi.fn();
await ctx.storage.watch(watcher);
await writeFile(resolve(dir, "s1/random_file"), "random");
await new Promise((resolve) => setTimeout(resolve, 500));
expect(watcher).toHaveBeenCalledWith("update", "s1:random_file");
});
const invalidKeys = [
'../foobar',
'..:foobar',
'../',
'..:',
'..'
]
const invalidKeys = ["../foobar", "..:foobar", "../", "..:", ".."];
for (const key of invalidKeys) {
it('disallow path travesal: ' , async () => {
await expect(ctx.storage.getItem(key)).rejects.toThrow('Invalid key')
})
it("disallow path travesal: ", async () => {
await expect(ctx.storage.getItem(key)).rejects.toThrow("Invalid key");
});
}
it('allow double dots in filename: ' , async () => {
await ctx.storage.setItem('s1/te..st..js', 'ok')
expect(await ctx.storage.getItem('s1/te..st..js')).toBe('ok')
})
}
})
})
it("allow double dots in filename: ", async () => {
await ctx.storage.setItem("s1/te..st..js", "ok");
expect(await ctx.storage.getItem("s1/te..st..js")).toBe("ok");
});
},
});
});

View File

@@ -1,30 +1,33 @@
import { describe, it, expect } from 'vitest'
import driver from '../../src/drivers/http'
import { createStorage } from '../../src'
import { createStorageServer } from '../../src/server'
import { listen } from 'listhen'
import { describe, it, expect } from "vitest";
import driver from "../../src/drivers/http";
import { createStorage } from "../../src";
import { createStorageServer } from "../../src/server";
import { listen } from "listhen";
describe('drivers: http', () => {
it('basic', async () => {
const storage = createStorage()
const server = createStorageServer(storage)
describe("drivers: http", () => {
it("basic", async () => {
const storage = createStorage();
const server = createStorageServer(storage);
const { url, close } = await listen(server.handle, {
port: { random: true }
})
storage.mount('/http', driver({ base: url }))
port: { random: true },
});
storage.mount("/http", driver({ base: url }));
expect(await storage.hasItem('/http/foo')).toBe(false)
expect(await storage.hasItem("/http/foo")).toBe(false);
await storage.setItem('/http/foo', 'bar')
expect(await storage.getItem('http:foo')).toBe('bar')
expect(await storage.hasItem('/http/foo')).toBe(true)
await storage.setItem("/http/foo", "bar");
expect(await storage.getItem("http:foo")).toBe("bar");
expect(await storage.hasItem("/http/foo")).toBe(true);
const date = new Date()
await storage.setMeta('/http/foo', { mtime: date })
const date = new Date();
await storage.setMeta("/http/foo", { mtime: date });
expect(await storage.getMeta('/http/foo')).toMatchObject({ mtime: date, status: 200 })
expect(await storage.getMeta("/http/foo")).toMatchObject({
mtime: date,
status: 200,
});
await close()
})
})
await close();
});
});

View File

@@ -1,64 +1,64 @@
import { describe, it, expect, vi } from 'vitest'
import driver from '../../src/drivers/localstorage'
import { testDriver } from './utils'
import { JSDOM } from 'jsdom'
import { describe, it, expect, vi } from "vitest";
import driver from "../../src/drivers/localstorage";
import { testDriver } from "./utils";
import { JSDOM } from "jsdom";
describe('drivers: localstorage', () => {
const jsdom = new JSDOM('', {
url: 'http://localhost'
})
jsdom.virtualConsole.sendTo(console)
describe("drivers: localstorage", () => {
const jsdom = new JSDOM("", {
url: "http://localhost",
});
jsdom.virtualConsole.sendTo(console);
testDriver({
driver: driver({ window: jsdom.window as unknown as typeof window }),
additionalTests: (ctx => {
it('check localstorage', () => {
expect(jsdom.window.localStorage.getItem('s1:a')).toBe('test_data')
})
it('watch localstorage', async () => {
const watcher = vi.fn()
await ctx.storage.watch(watcher)
additionalTests: (ctx) => {
it("check localstorage", () => {
expect(jsdom.window.localStorage.getItem("s1:a")).toBe("test_data");
});
it("watch localstorage", async () => {
const watcher = vi.fn();
await ctx.storage.watch(watcher);
// Emulate
// jsdom.window.localStorage.setItem('s1:random_file', 'random')
const ev = jsdom.window.document.createEvent('CustomEvent')
ev.initEvent('storage', true)
const ev = jsdom.window.document.createEvent("CustomEvent");
ev.initEvent("storage", true);
// @ts-ignore
ev.key = 's1:random_file'
ev.key = "s1:random_file";
// @ts-ignore
ev.newValue = 'random'
jsdom.window.dispatchEvent(ev)
ev.newValue = "random";
jsdom.window.dispatchEvent(ev);
expect(watcher).toHaveBeenCalledWith('update', 's1:random_file')
})
it('unwatch localstorage', async () => {
const watcher = vi.fn()
const unwatch = await ctx.storage.watch(watcher)
expect(watcher).toHaveBeenCalledWith("update", "s1:random_file");
});
it("unwatch localstorage", async () => {
const watcher = vi.fn();
const unwatch = await ctx.storage.watch(watcher);
// Emulate
// jsdom.window.localStorage.setItem('s1:random_file', 'random')
const ev = jsdom.window.document.createEvent('CustomEvent')
ev.initEvent('storage', true)
const ev = jsdom.window.document.createEvent("CustomEvent");
ev.initEvent("storage", true);
// @ts-ignore
ev.key = 's1:random_file'
ev.key = "s1:random_file";
// @ts-ignore
ev.newValue = 'random'
const ev2 = jsdom.window.document.createEvent('CustomEvent')
ev2.initEvent('storage', true)
ev.newValue = "random";
const ev2 = jsdom.window.document.createEvent("CustomEvent");
ev2.initEvent("storage", true);
// @ts-ignore
ev2.key = 's1:random_file2'
ev2.key = "s1:random_file2";
// @ts-ignore
ev2.newValue = 'random'
jsdom.window.dispatchEvent(ev)
ev2.newValue = "random";
await unwatch()
jsdom.window.dispatchEvent(ev2)
jsdom.window.dispatchEvent(ev);
expect(watcher).toHaveBeenCalledWith('update', 's1:random_file')
expect(watcher).toHaveBeenCalledTimes(1)
})
})
})
})
await unwatch();
jsdom.window.dispatchEvent(ev2);
expect(watcher).toHaveBeenCalledWith("update", "s1:random_file");
expect(watcher).toHaveBeenCalledTimes(1);
});
},
});
});

View File

@@ -1,9 +1,9 @@
import { describe } from 'vitest'
import driver from '../../src/drivers/memory'
import { testDriver } from './utils'
import { describe } from "vitest";
import driver from "../../src/drivers/memory";
import { testDriver } from "./utils";
describe('drivers: memory', () => {
describe("drivers: memory", () => {
testDriver({
driver: driver()
})
})
driver: driver(),
});
});

View File

@@ -1,13 +1,13 @@
import { describe } from 'vitest'
import driver from '../../src/drivers/overlay'
import memory from '../../src/drivers/memory'
import { testDriver } from './utils'
import { describe } from "vitest";
import driver from "../../src/drivers/overlay";
import memory from "../../src/drivers/memory";
import { testDriver } from "./utils";
describe('drivers: overlay', () => {
const [s1, s2] = [memory(), memory()]
describe("drivers: overlay", () => {
const [s1, s2] = [memory(), memory()];
testDriver({
driver: driver({
layers: [s1, s2]
})
})
})
layers: [s1, s2],
}),
});
});

View File

@@ -1,73 +1,79 @@
import { it, expect } from 'vitest'
import { Storage, Driver, createStorage, restoreSnapshot } from '../../src'
import { it, expect } from "vitest";
import { Storage, Driver, createStorage, restoreSnapshot } from "../../src";
export interface TestContext {
storage: Storage
driver: Driver
storage: Storage;
driver: Driver;
}
export interface TestOptions {
driver: Driver
additionalTests?: (ctx: TestContext) => void
driver: Driver;
additionalTests?: (ctx: TestContext) => void;
}
export function testDriver (opts: TestOptions) {
export function testDriver(opts: TestOptions) {
const ctx: TestContext = {
storage: createStorage(),
driver: opts.driver
}
driver: opts.driver,
};
it('init', async () => {
ctx.storage = createStorage({ driver: opts.driver })
await restoreSnapshot(ctx.storage, { initial: 'works' })
expect(await ctx.storage.getItem('initial')).toBe('works')
await ctx.storage.clear()
})
it("init", async () => {
ctx.storage = createStorage({ driver: opts.driver });
await restoreSnapshot(ctx.storage, { initial: "works" });
expect(await ctx.storage.getItem("initial")).toBe("works");
await ctx.storage.clear();
});
it('initial state', async () => {
expect(await ctx.storage.hasItem('s1:a')).toBe(false)
expect(await ctx.storage.getItem('s2:a')).toBe(null)
expect(await ctx.storage.getKeys()).toMatchObject([])
})
it("initial state", async () => {
expect(await ctx.storage.hasItem("s1:a")).toBe(false);
expect(await ctx.storage.getItem("s2:a")).toBe(null);
expect(await ctx.storage.getKeys()).toMatchObject([]);
});
it('setItem', async () => {
await ctx.storage.setItem('s1:a', 'test_data')
await ctx.storage.setItem('s2:a', 'test_data')
expect(await ctx.storage.hasItem('s1:a')).toBe(true)
expect(await ctx.storage.getItem('s1:a')).toBe('test_data')
})
it("setItem", async () => {
await ctx.storage.setItem("s1:a", "test_data");
await ctx.storage.setItem("s2:a", "test_data");
expect(await ctx.storage.hasItem("s1:a")).toBe(true);
expect(await ctx.storage.getItem("s1:a")).toBe("test_data");
});
it('getKeys', async () => {
expect(await ctx.storage.getKeys().then(k => k.sort())).toMatchObject(['s1:a', 's2:a'].sort())
expect(await ctx.storage.getKeys('s1').then(k => k.sort())).toMatchObject(['s1:a'].sort())
})
it("getKeys", async () => {
expect(await ctx.storage.getKeys().then((k) => k.sort())).toMatchObject(
["s1:a", "s2:a"].sort()
);
expect(await ctx.storage.getKeys("s1").then((k) => k.sort())).toMatchObject(
["s1:a"].sort()
);
});
it('serialize (object)', async () => {
await ctx.storage.setItem('/data/test.json', { json: 'works' })
expect(await ctx.storage.getItem('/data/test.json')).toMatchObject({ json: 'works' })
})
it("serialize (object)", async () => {
await ctx.storage.setItem("/data/test.json", { json: "works" });
expect(await ctx.storage.getItem("/data/test.json")).toMatchObject({
json: "works",
});
});
it('serialize (primitive)', async () => {
await ctx.storage.setItem('/data/true.json', true)
expect(await ctx.storage.getItem('/data/true.json')).toBe(true)
})
it("serialize (primitive)", async () => {
await ctx.storage.setItem("/data/true.json", true);
expect(await ctx.storage.getItem("/data/true.json")).toBe(true);
});
if (opts.additionalTests) {
opts.additionalTests(ctx)
opts.additionalTests(ctx);
}
it('removeItem', async () => {
await ctx.storage.removeItem('s1:a')
expect(await ctx.storage.hasItem('s1:a')).toBe(false)
expect(await ctx.storage.getItem('s1:a')).toBe(null)
})
it("removeItem", async () => {
await ctx.storage.removeItem("s1:a");
expect(await ctx.storage.hasItem("s1:a")).toBe(false);
expect(await ctx.storage.getItem("s1:a")).toBe(null);
});
it('clear', async () => {
await ctx.storage.clear()
expect(await ctx.storage.getKeys()).toMatchObject([])
})
it("clear", async () => {
await ctx.storage.clear();
expect(await ctx.storage.getKeys()).toMatchObject([]);
});
it('dispose', async () => {
await ctx.storage.dispose()
})
it("dispose", async () => {
await ctx.storage.dispose();
});
}

View File

@@ -9,10 +9,11 @@ describe("server", () => {
const storage = createStorage();
const storageServer = createStorageServer(storage);
const { close, url: serverURL } = await listen(storageServer.handle, {
port: { random: true }
port: { random: true },
});
const fetchStorage = (url: string, options?: any) => $fetch(url, { baseURL: serverURL, ...options });
const fetchStorage = (url: string, options?: any) =>
$fetch(url, { baseURL: serverURL, ...options });
expect(await fetchStorage("foo", {})).toMatchObject([]);
@@ -20,7 +21,9 @@ describe("server", () => {
await storage.setMeta("foo/bar", { mtime: new Date() });
expect(await fetchStorage("foo/bar")).toBe("bar");
expect(await fetchStorage("foo/bar", { method: "PUT", body: "updated" })).toBe("OK");
expect(
await fetchStorage("foo/bar", { method: "PUT", body: "updated" })
).toBe("OK");
expect(await fetchStorage("foo/bar")).toBe("updated");
expect(await fetchStorage("/")).toMatchObject(["foo/bar"]);

View File

@@ -1,10 +1,15 @@
import { describe, it, expect, vi } from "vitest";
import { createStorage, snapshot, restoreSnapshot, prefixStorage } from "../src";
import {
createStorage,
snapshot,
restoreSnapshot,
prefixStorage,
} from "../src";
import memory from "../src/drivers/memory";
const data = {
"etc:conf": "test",
"data:foo": 123
"data:foo": 123,
};
describe("storage", () => {