Port to deno
This commit is contained in:
parent
e83a1fda32
commit
33bc8f1863
31 changed files with 347 additions and 9566 deletions
|
@ -1,2 +0,0 @@
|
|||
node_modules
|
||||
.venv
|
|
@ -1,10 +1,9 @@
|
|||
root = true
|
||||
|
||||
[*]
|
||||
[*.{ts,json}]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
end_of_line = lf
|
||||
max_line_length = off
|
||||
|
|
8
.gitignore
vendored
8
.gitignore
vendored
|
@ -1,5 +1,3 @@
|
|||
.venv
|
||||
node_modules
|
||||
.rts2_cache_*
|
||||
.coverage
|
||||
/dist/
|
||||
deno.d.ts
|
||||
.vscode
|
||||
.local
|
||||
|
|
|
@ -1,11 +0,0 @@
|
|||
image: node:latest
|
||||
|
||||
cache:
|
||||
paths:
|
||||
- node_modules/
|
||||
|
||||
test:
|
||||
before_script:
|
||||
- npm install
|
||||
script:
|
||||
- npm test
|
8
.vscode/settings.json
vendored
8
.vscode/settings.json
vendored
|
@ -1,3 +1,7 @@
|
|||
{
|
||||
"typescript.tsdk": "node_modules/typescript/lib"
|
||||
}
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"deno.enable": true,
|
||||
"deno.suggest.imports.hosts": {
|
||||
"https://deno.land": false
|
||||
}
|
||||
}
|
||||
|
|
11
Dockerfile
11
Dockerfile
|
@ -1,11 +0,0 @@
|
|||
FROM alpine
|
||||
|
||||
RUN apk add --no-cache nodejs npm
|
||||
|
||||
ADD . /app
|
||||
WORKDIR /app
|
||||
|
||||
RUN npm install
|
||||
|
||||
EXPOSE 5001
|
||||
CMD npm run storageserver
|
68
README.md
68
README.md
|
@ -1,67 +1,3 @@
|
|||
tk-storage
|
||||
==========
|
||||
# typescript/storage
|
||||
|
||||
[![pipeline status](https://gitlab.com/thunderk/tk-storage/badges/master/pipeline.svg)](https://gitlab.com/thunderk/tk-storage/commits/master)
|
||||
[![coverage report](https://gitlab.com/thunderk/tk-storage/badges/master/coverage.svg)](https://gitlab.com/thunderk/tk-storage/commits/master)
|
||||
[![npm version](https://img.shields.io/npm/v/tk-storage.svg)](https://npmjs.com/tk-storage)
|
||||
[![npm size](https://img.shields.io/bundlephobia/min/tk-storage.svg)](https://bundlephobia.com/result?p=tk-storage)
|
||||
|
||||
About
|
||||
-----
|
||||
|
||||
Javascript/Typescript persistent storage, with key-value stores as foundation.
|
||||
|
||||
Typescript definitions are included.
|
||||
|
||||
Issues can be reported on [GitLab](https://gitlab.com/thunderk/tk-storage/issues).
|
||||
|
||||
Install
|
||||
-------
|
||||
|
||||
Import in node:
|
||||
|
||||
```shell
|
||||
npm install tk-storage
|
||||
```
|
||||
|
||||
```javascript
|
||||
import { getLocalStorage } from "tk-storage";
|
||||
const storage = getLocalStorage("myapp");
|
||||
```
|
||||
|
||||
Import in browser:
|
||||
|
||||
```html
|
||||
<script src="https://unpkg.com/tk-storage"></script>
|
||||
```
|
||||
|
||||
```javascript
|
||||
const storage = tkStorage.getLocalStorage("myapp");
|
||||
```
|
||||
|
||||
Use
|
||||
---
|
||||
|
||||
To get a storage locally persistent (saved in browser data or on disk for Node.js):
|
||||
|
||||
```javascript
|
||||
const storage = getLocalStorage("myapp");
|
||||
await storage.get("key"); // => null
|
||||
await storage.set("key", "value");
|
||||
await storage.get("key"); // => "value"
|
||||
```
|
||||
|
||||
To get a storage remotely persistent (saved on a compliant server):
|
||||
|
||||
```javascript
|
||||
const storage = getRemoteStorage("myapp", "https://tk-storage.example.io/", { shared: true });
|
||||
await storage.get("key"); // => null
|
||||
await storage.set("key", "value");
|
||||
await storage.get("key"); // => "value"
|
||||
```
|
||||
|
||||
Run a server for remote storage:
|
||||
|
||||
```shell
|
||||
npm run storageserver
|
||||
```
|
||||
[![Build Status](https://thunderk.visualstudio.com/typescript/_apis/build/status/storage?branchName=master)](https://dev.azure.com/thunderk/typescript/_build?pipelineNameFilter=storage)
|
||||
|
|
|
@ -1,16 +0,0 @@
|
|||
# Activation script for virtual nodejs environment
|
||||
# Usage:
|
||||
# source activate_node
|
||||
|
||||
vdir="./.venv"
|
||||
expected="12.13.0"
|
||||
|
||||
if [ \! -f "./activate_node" ]
|
||||
then
|
||||
echo "Not in project directory"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
test -x "${vdir}/bin/nodeenv" || ( python3 -m venv "${vdir}" && "${vdir}/bin/pip" install --upgrade nodeenv )
|
||||
test "$(${vdir}/node/bin/nodejs --version)" = "v${expected}" || "${vdir}/bin/nodeenv" --node=${expected} --force "${vdir}/node"
|
||||
source "${vdir}/node/bin/activate"
|
|
@ -1,13 +1,5 @@
|
|||
import { KeyValueStorage, MemoryStorage, RefScopedStorage } from "./basic";
|
||||
|
||||
export async function basicCheck(storage: KeyValueStorage): Promise<void> {
|
||||
expect(await storage.get("test")).toBe(null);
|
||||
await storage.set("test", "val");
|
||||
expect(await storage.get("test")).toBe("val");
|
||||
expect(await storage.get("notest")).toBe(null);
|
||||
await storage.set("test", null);
|
||||
expect(await storage.get("test")).toBe(null);
|
||||
}
|
||||
import { basicCheck, describe, expect, it } from "./testing.ts";
|
||||
import { MemoryStorage, RefScopedStorage } from "./basic.ts";
|
||||
|
||||
describe(MemoryStorage, () => {
|
||||
it("stores values in memory", async () => {
|
|
@ -1,18 +1,18 @@
|
|||
import uuid1 from "uuid/v1";
|
||||
import { uuid1 } from "./deps.ts";
|
||||
|
||||
/**
|
||||
* Standard interface for the simplest key-value store
|
||||
*/
|
||||
export interface KeyValueStorage {
|
||||
get(key: string): Promise<string | null>
|
||||
set(key: string, value: string | null): Promise<void>
|
||||
get(key: string): Promise<string | null>;
|
||||
set(key: string, value: string | null): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Basic memory implementation
|
||||
*/
|
||||
export class MemoryStorage implements KeyValueStorage {
|
||||
private store: { [key: string]: string } = {}
|
||||
private store: { [key: string]: string } = {};
|
||||
|
||||
async get(key: string): Promise<string | null> {
|
||||
const result = this.store[key];
|
||||
|
@ -28,7 +28,7 @@ export class MemoryStorage implements KeyValueStorage {
|
|||
}
|
||||
}
|
||||
|
||||
type StorageDelegate = KeyValueStorage | (() => KeyValueStorage)
|
||||
type StorageDelegate = KeyValueStorage | (() => KeyValueStorage);
|
||||
|
||||
function fromDelegate(delegate: StorageDelegate): KeyValueStorage {
|
||||
return (typeof delegate === "function") ? delegate() : delegate;
|
||||
|
@ -38,7 +38,7 @@ function fromDelegate(delegate: StorageDelegate): KeyValueStorage {
|
|||
* Wrap a storage, scoping the keys using a suffix
|
||||
*/
|
||||
export class ScopedStorage implements KeyValueStorage {
|
||||
private readonly target: KeyValueStorage
|
||||
private readonly target: KeyValueStorage;
|
||||
|
||||
constructor(target: StorageDelegate, private readonly suffix: string) {
|
||||
this.target = fromDelegate(target);
|
||||
|
@ -59,9 +59,9 @@ export class ScopedStorage implements KeyValueStorage {
|
|||
* The namespace is persisted in a reference storage (used unscoped)
|
||||
*/
|
||||
export class RefScopedStorage implements KeyValueStorage {
|
||||
private readonly reference: KeyValueStorage
|
||||
private readonly target: KeyValueStorage
|
||||
private inner?: KeyValueStorage
|
||||
private readonly reference: KeyValueStorage;
|
||||
private readonly target: KeyValueStorage;
|
||||
private inner?: KeyValueStorage;
|
||||
|
||||
constructor(reference: StorageDelegate, target: StorageDelegate) {
|
||||
this.reference = fromDelegate(reference);
|
||||
|
@ -74,7 +74,7 @@ export class RefScopedStorage implements KeyValueStorage {
|
|||
if (suffix) {
|
||||
return new ScopedStorage(this.target, suffix);
|
||||
} else {
|
||||
const suffix = uuid1();
|
||||
const suffix = uuid1.generate().toString();
|
||||
await this.reference.set(refkey, suffix);
|
||||
return new ScopedStorage(this.target, suffix);
|
||||
}
|
4
deps.ts
Normal file
4
deps.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
export { v1 as uuid1 } from "https://deno.land/std@0.99.0/uuid/mod.ts";
|
||||
export { json, opine } from "https://deno.land/x/opine@1.5.3/mod.ts";
|
||||
export { opineCors } from "https://deno.land/x/cors@v1.2.2/mod.ts";
|
||||
export { readAll } from "https://deno.land/std@0.99.0/io/util.ts";
|
11
dist/index.html
vendored
11
dist/index.html
vendored
|
@ -1,11 +0,0 @@
|
|||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<script src="tk-storage.umd.js"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
</body>
|
||||
|
||||
</html>
|
|
@ -1,27 +0,0 @@
|
|||
module.exports = {
|
||||
transform: {
|
||||
"^.+\\.ts$": "ts-jest"
|
||||
},
|
||||
moduleFileExtensions: [
|
||||
"ts",
|
||||
"js",
|
||||
"json",
|
||||
"node"
|
||||
],
|
||||
watchPathIgnorePatterns: [
|
||||
"<rootDir>/dist/",
|
||||
"<rootDir>/node_modules/",
|
||||
],
|
||||
restoreMocks: true,
|
||||
collectCoverage: true,
|
||||
collectCoverageFrom: [
|
||||
"src/**/*.ts",
|
||||
"!src/**/*.test.ts",
|
||||
],
|
||||
coverageDirectory: ".coverage",
|
||||
coverageReporters: [
|
||||
"lcovonly",
|
||||
"html",
|
||||
"text-summary"
|
||||
]
|
||||
}
|
21
local.test.ts
Normal file
21
local.test.ts
Normal file
|
@ -0,0 +1,21 @@
|
|||
import {
|
||||
basicCheck,
|
||||
describe,
|
||||
disableLocalStorage,
|
||||
expect,
|
||||
it,
|
||||
} from "./testing.ts";
|
||||
import { LocalStorage } from "./local.ts";
|
||||
|
||||
describe(LocalStorage, () => {
|
||||
it("uses localStorage as storage", async () => {
|
||||
const storage = new LocalStorage();
|
||||
await basicCheck(storage);
|
||||
|
||||
await disableLocalStorage(async () => {
|
||||
expect(() => new LocalStorage()).toThrow(
|
||||
"localStorage not available",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,9 +1,9 @@
|
|||
import { KeyValueStorage } from "./basic";
|
||||
import { KeyValueStorage } from "./basic.ts";
|
||||
|
||||
/**
|
||||
* Key-value store using localStorage
|
||||
*/
|
||||
export class BrowserLocalStorage implements KeyValueStorage {
|
||||
export class LocalStorage implements KeyValueStorage {
|
||||
constructor() {
|
||||
if (typeof localStorage == "undefined" || !localStorage) {
|
||||
throw new Error("localStorage not available");
|
|
@ -1,19 +1,8 @@
|
|||
import { getLocalStorage, getMemoryStorage, getRemoteStorage } from ".";
|
||||
import { MemoryStorage, ScopedStorage } from "./basic";
|
||||
import { BrowserLocalStorage } from "./browser";
|
||||
import { NodeDirectoryStorage } from "./node";
|
||||
import { forceNodeStoragesInTempDir } from "./node.test";
|
||||
import { startTestServer } from "./remote.test";
|
||||
|
||||
const localStorage = (window as any).localStorage;
|
||||
|
||||
describe(getLocalStorage, () => {
|
||||
forceNodeStoragesInTempDir();
|
||||
|
||||
afterEach(() => {
|
||||
(window as any).localStorage = localStorage;
|
||||
});
|
||||
import { getLocalStorage, getMemoryStorage, getRemoteStorage } from "./mod.ts";
|
||||
import { MemoryStorage, ScopedStorage } from "./basic.ts";
|
||||
import { describe, expect, it, runTestServer } from "./testing.ts";
|
||||
|
||||
/*describe(getLocalStorage, () => {
|
||||
it("returns the best storage available", async () => {
|
||||
const mockWarn = jest.spyOn(console, "warn").mockImplementation();
|
||||
|
||||
|
@ -35,32 +24,36 @@ describe(getLocalStorage, () => {
|
|||
storage = getLocalStorage("app1");
|
||||
expect(storage).toBeInstanceOf(NodeDirectoryStorage);
|
||||
|
||||
(NodeDirectoryStorage.findUserData as any).mockImplementation(() => { throw new Error() });
|
||||
(NodeDirectoryStorage.findUserData as any).mockImplementation(() => {
|
||||
throw new Error();
|
||||
});
|
||||
|
||||
storage = getLocalStorage("app1");
|
||||
expect(storage).toBeInstanceOf(MemoryStorage);
|
||||
expect(mockWarn).toHaveBeenCalledWith("No persistent storage available, using in-memory volatile storage");
|
||||
expect(mockWarn).toHaveBeenCalledWith(
|
||||
"No persistent storage available, using in-memory volatile storage",
|
||||
);
|
||||
});
|
||||
});
|
||||
});*/
|
||||
|
||||
describe(getRemoteStorage, () => {
|
||||
const server = startTestServer();
|
||||
|
||||
it("returns a scoped storage", async () => {
|
||||
let storage = getRemoteStorage("app1", `http://localhost:${server.port()}`);
|
||||
await runTestServer(async (url) => {
|
||||
let storage = getRemoteStorage("app1", url);
|
||||
|
||||
await storage.set("testkey", "testvalue");
|
||||
expect(await storage.get("testkey")).toBe("testvalue");
|
||||
await storage.set("testkey", "testvalue");
|
||||
expect(await storage.get("testkey")).toBe("testvalue");
|
||||
|
||||
storage = getRemoteStorage("app1", `http://localhost:${server.port()}`);
|
||||
expect(await storage.get("testkey")).toBe("testvalue");
|
||||
storage = getRemoteStorage("app1", url);
|
||||
expect(await storage.get("testkey")).toBe("testvalue");
|
||||
|
||||
storage = getRemoteStorage("app2", `http://localhost:${server.port()}`);
|
||||
expect(await storage.get("testkey")).toBeNull();
|
||||
storage = getRemoteStorage("app2", url);
|
||||
expect(await storage.get("testkey")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("scopes in a private namespace on shared mode", async () => {
|
||||
|
||||
// TODO
|
||||
});
|
||||
});
|
||||
|
63
mod.ts
Normal file
63
mod.ts
Normal file
|
@ -0,0 +1,63 @@
|
|||
import {
|
||||
KeyValueStorage,
|
||||
MemoryStorage,
|
||||
RefScopedStorage,
|
||||
ScopedStorage,
|
||||
} from "./basic.ts";
|
||||
import { LocalStorage } from "./local.ts";
|
||||
import { RestRemoteStorage } from "./remote.ts";
|
||||
|
||||
/**
|
||||
* Base type for storage usage
|
||||
*/
|
||||
export type TKStorage = KeyValueStorage;
|
||||
|
||||
/**
|
||||
* Exposed classes
|
||||
*/
|
||||
export {
|
||||
LocalStorage,
|
||||
MemoryStorage,
|
||||
RefScopedStorage,
|
||||
RestRemoteStorage,
|
||||
ScopedStorage,
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the best "local" storage available
|
||||
*/
|
||||
export function getLocalStorage(appname: string): TKStorage {
|
||||
try {
|
||||
return new ScopedStorage(new LocalStorage(), appname);
|
||||
} catch {
|
||||
console.warn(
|
||||
"No persistent storage available, using in-memory volatile storage",
|
||||
);
|
||||
return new MemoryStorage();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a remote storage, based on an URI
|
||||
*
|
||||
* If *shared* is set to true, a namespace will be used to avoid collisions, using getLocalStorage to persist it
|
||||
*/
|
||||
export function getRemoteStorage(
|
||||
appname: string,
|
||||
uri: string,
|
||||
options = { shared: false },
|
||||
): TKStorage {
|
||||
let storage: TKStorage = new RestRemoteStorage(appname, uri);
|
||||
storage = new ScopedStorage(storage, appname);
|
||||
if (options.shared) {
|
||||
storage = new RefScopedStorage(getLocalStorage(appname), storage);
|
||||
}
|
||||
return storage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a in-memory volatile storage
|
||||
*/
|
||||
export function getMemoryStorage(): TKStorage {
|
||||
return new MemoryStorage();
|
||||
}
|
8943
package-lock.json
generated
8943
package-lock.json
generated
File diff suppressed because it is too large
Load diff
60
package.json
60
package.json
|
@ -1,60 +0,0 @@
|
|||
{
|
||||
"name": "tk-storage",
|
||||
"version": "0.3.0",
|
||||
"description": "Javascript/Typescript persistent storage, with key-value stores as foundation",
|
||||
"main": "dist/tk-storage.umd.js",
|
||||
"source": "src/index.ts",
|
||||
"module": "dist/tk-storage.modern.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"test": "jest",
|
||||
"normalize": "tk-base",
|
||||
"build": "microbundle build -f modern,umd",
|
||||
"dev": "run-p dev:*",
|
||||
"dev:test": "jest --watchAll",
|
||||
"dev:build": "microbundle watch -f modern,umd",
|
||||
"storageserver": "ts-node src/server.ts",
|
||||
"docker:build": "docker build -t thunderk/tk-storage .",
|
||||
"docker:push": "docker push thunderk/tk-storage",
|
||||
"docker:run": "docker run -it --rm thunderk/tk-storage",
|
||||
"docker:publish": "run-s docker:build docker:push",
|
||||
"prepare": "npm run build",
|
||||
"prepublishOnly": "npm test",
|
||||
"dev:serve": "live-server --host=localhost --port=5000 --no-browser --ignorePattern='.*\\.d\\.ts' dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"xmlhttprequest": "^1.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.6",
|
||||
"@types/express": "^4.17.2",
|
||||
"@types/uuid": "^3.4.6",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.17.1",
|
||||
"get-port": "^5.0.0",
|
||||
"tk-base": "^0.2.5",
|
||||
"uuid": "^3.3.3"
|
||||
},
|
||||
"files": [
|
||||
"/src",
|
||||
"/dist"
|
||||
],
|
||||
"author": {
|
||||
"name": "Michaël Lemaire",
|
||||
"url": "https://thunderk.net"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://code.thunderk.net/tslib/tk-storage.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://gitlab.com/thunderk/tk-storage/issues"
|
||||
},
|
||||
"homepage": "https://code.thunderk.net/tslib/tk-storage",
|
||||
"license": "ISC",
|
||||
"keywords": [
|
||||
"typescript",
|
||||
"storage",
|
||||
"database"
|
||||
]
|
||||
}
|
11
remote.test.ts
Normal file
11
remote.test.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
import { basicCheck, describe, it, runTestServer } from "./testing.ts";
|
||||
import { RestRemoteStorage } from "./remote.ts";
|
||||
|
||||
describe(RestRemoteStorage, () => {
|
||||
it("communicates with the server", async () => {
|
||||
await runTestServer(async (url) => {
|
||||
const storage = new RestRemoteStorage("test", url);
|
||||
await basicCheck(storage);
|
||||
});
|
||||
});
|
||||
});
|
66
remote.ts
Normal file
66
remote.ts
Normal file
|
@ -0,0 +1,66 @@
|
|||
import { KeyValueStorage } from "./basic.ts";
|
||||
|
||||
function protectKey(key: string): string {
|
||||
return encodeURIComponent(key);
|
||||
}
|
||||
|
||||
type RestClient = (
|
||||
method: "GET" | "POST" | "PUT" | "DELETE",
|
||||
uri: string,
|
||||
body?: string,
|
||||
) => Promise<string | null>;
|
||||
export const HEADER_REQUESTER = "X-TK-Storage-App";
|
||||
export const HEADER_REPLYIER = "X-TK-Storage-Control";
|
||||
|
||||
/**
|
||||
* Storage on a remote server, directly usable using HTTP REST verbs
|
||||
*/
|
||||
export class RestRemoteStorage implements KeyValueStorage {
|
||||
readonly appname: string;
|
||||
readonly base_url: string;
|
||||
readonly client: RestClient;
|
||||
|
||||
constructor(appname: string, url: string) {
|
||||
this.appname = appname;
|
||||
this.base_url = (url[url.length - 1] != "/") ? url + "/" : url;
|
||||
this.client = this.createClient();
|
||||
}
|
||||
|
||||
async get(key: string): Promise<string | null> {
|
||||
key = protectKey(key);
|
||||
return await this.client("GET", key);
|
||||
}
|
||||
|
||||
async set(key: string, value: string | null): Promise<void> {
|
||||
key = protectKey(key);
|
||||
if (value === null) {
|
||||
await this.client("DELETE", key);
|
||||
} else {
|
||||
await this.client("PUT", key, value);
|
||||
}
|
||||
}
|
||||
|
||||
private createClient(): RestClient {
|
||||
const client: RestClient = async (method, path, body?) => {
|
||||
const response = await fetch(`${this.base_url}${path}`, {
|
||||
method,
|
||||
body,
|
||||
headers: {
|
||||
[HEADER_REQUESTER]: this.appname,
|
||||
},
|
||||
});
|
||||
if (response.headers.get(HEADER_REPLYIER) != "ok") {
|
||||
throw new Error("storage not compatible with tk-storage");
|
||||
} else if (response.status == 200) {
|
||||
return await response.text();
|
||||
} else if (response.status == 404) {
|
||||
return null;
|
||||
} else {
|
||||
throw new Error(
|
||||
`http error ${response.status}: ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
return client;
|
||||
}
|
||||
}
|
71
server.ts
Executable file
71
server.ts
Executable file
|
@ -0,0 +1,71 @@
|
|||
#!/usr/bin/env -S deno run --allow-read --allow-net
|
||||
import { getLocalStorage } from "./mod.ts";
|
||||
import { KeyValueStorage } from "./basic.ts";
|
||||
import { HEADER_REPLYIER, HEADER_REQUESTER } from "./remote.ts";
|
||||
import { json, opine, opineCors, readAll } from "./deps.ts";
|
||||
|
||||
const PORT = 5001;
|
||||
|
||||
type Server = {
|
||||
close: () => Promise<void>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Start a server compliant with RestRemoteStorage
|
||||
*/
|
||||
export function startRestServer(
|
||||
port: number,
|
||||
storage: KeyValueStorage,
|
||||
): Promise<Server> {
|
||||
const app = opine();
|
||||
app.use(
|
||||
opineCors({ exposedHeaders: [HEADER_REPLYIER] }),
|
||||
json(),
|
||||
(req, res, next) => {
|
||||
if (req.get(HEADER_REQUESTER)) {
|
||||
res.set(HEADER_REPLYIER, "ok");
|
||||
next();
|
||||
} else {
|
||||
res.setStatus(400).send();
|
||||
}
|
||||
},
|
||||
);
|
||||
app.get("*", (req, res, next) => {
|
||||
storage.get(req.path).then((result) => {
|
||||
if (result === null) {
|
||||
res.setStatus(404).send();
|
||||
} else {
|
||||
res.send(result);
|
||||
}
|
||||
}).catch(next);
|
||||
});
|
||||
app.put("*", (req, res, next) => {
|
||||
readAll(req.body).then((body) => {
|
||||
const value = new TextDecoder().decode(body);
|
||||
storage.set(req.path, value).then(() => {
|
||||
res.send();
|
||||
}).catch(next);
|
||||
}).catch(next);
|
||||
});
|
||||
app.delete("*", (req, res, next) => {
|
||||
storage.set(req.path, null).then(() => {
|
||||
res.send();
|
||||
}).catch(next);
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const server = app.listen(port, () => {
|
||||
resolve({
|
||||
close: () =>
|
||||
new Promise((resolve, reject) => {
|
||||
server.close();
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
await startRestServer(PORT, getLocalStorage("tk-storage-server"));
|
||||
console.log(`Server running, use http://localhost:${PORT} to connect`);
|
||||
}
|
|
@ -1,18 +0,0 @@
|
|||
import { basicCheck } from "./basic.test";
|
||||
import { BrowserLocalStorage } from "./browser";
|
||||
|
||||
const localStorage = (window as any).localStorage;
|
||||
|
||||
describe(BrowserLocalStorage, () => {
|
||||
it("uses localStorage as storage", async () => {
|
||||
const storage = new BrowserLocalStorage();
|
||||
await basicCheck(storage);
|
||||
|
||||
delete (window as any)["localStorage"];
|
||||
expect(() => new BrowserLocalStorage()).toThrowError("localStorage not available");
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
(window as any).localStorage = localStorage;
|
||||
});
|
51
src/index.ts
51
src/index.ts
|
@ -1,51 +0,0 @@
|
|||
import { KeyValueStorage, MemoryStorage, RefScopedStorage, ScopedStorage } from "./basic";
|
||||
import { BrowserLocalStorage } from "./browser";
|
||||
import { NodeDirectoryStorage } from "./node";
|
||||
import { RestRemoteStorage } from "./remote";
|
||||
|
||||
/**
|
||||
* Base type for storage usage
|
||||
*/
|
||||
export type TKStorage = KeyValueStorage
|
||||
|
||||
/**
|
||||
* Exposed classes
|
||||
*/
|
||||
export { MemoryStorage, ScopedStorage, RefScopedStorage, BrowserLocalStorage, NodeDirectoryStorage, RestRemoteStorage };
|
||||
|
||||
/**
|
||||
* Get the best "local" storage available
|
||||
*/
|
||||
export function getLocalStorage(appname: string): TKStorage {
|
||||
try {
|
||||
return new ScopedStorage(new BrowserLocalStorage(), appname);
|
||||
} catch {
|
||||
try {
|
||||
return new NodeDirectoryStorage(appname);
|
||||
} catch {
|
||||
console.warn("No persistent storage available, using in-memory volatile storage");
|
||||
return new MemoryStorage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a remote storage, based on an URI
|
||||
*
|
||||
* If *shared* is set to true, a namespace will be used to avoid collisions, using getLocalStorage to persist it
|
||||
*/
|
||||
export function getRemoteStorage(appname: string, uri: string, options = { shared: false }): TKStorage {
|
||||
let storage: TKStorage = new RestRemoteStorage(appname, uri);
|
||||
storage = new ScopedStorage(storage, appname);
|
||||
if (options.shared) {
|
||||
storage = new RefScopedStorage(getLocalStorage(appname), storage);
|
||||
}
|
||||
return storage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a in-memory volatile storage
|
||||
*/
|
||||
export function getMemoryStorage(): TKStorage {
|
||||
return new MemoryStorage();
|
||||
}
|
|
@ -1,30 +0,0 @@
|
|||
import { basicCheck } from "./basic.test";
|
||||
import { NodeDirectoryStorage } from "./node";
|
||||
|
||||
let tempdir: string | null = null
|
||||
|
||||
export function forceNodeStoragesInTempDir() {
|
||||
beforeEach(() => {
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
const tmpdir = tempdir = fs.mkdtempSync(path.join(os.tmpdir(), "tk-storage-testing"));
|
||||
jest.spyOn(NodeDirectoryStorage, "findUserData").mockReturnValue(tmpdir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (tempdir) {
|
||||
// TODO remove directory
|
||||
tempdir = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
describe(NodeDirectoryStorage, () => {
|
||||
forceNodeStoragesInTempDir();
|
||||
|
||||
it("uses a directory as storage", async () => {
|
||||
const storage = new NodeDirectoryStorage("test-tk-storage");
|
||||
await basicCheck(storage);
|
||||
});
|
||||
});
|
82
src/node.ts
82
src/node.ts
|
@ -1,82 +0,0 @@
|
|||
import { KeyValueStorage } from "./basic";
|
||||
|
||||
/**
|
||||
* Key-value store, using local file-system
|
||||
*/
|
||||
export class NodeDirectoryStorage implements KeyValueStorage {
|
||||
readonly directory: string
|
||||
private checked = false
|
||||
|
||||
constructor(appname: string) {
|
||||
if (typeof require === "undefined") {
|
||||
throw new Error("Storage only available in nodejs");
|
||||
}
|
||||
const path = require("path");
|
||||
this.directory = path.join(NodeDirectoryStorage.findUserData(), "tk-storage", appname);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the writable user data directory
|
||||
*/
|
||||
static findUserData(): string {
|
||||
const result = process.env.APPDATA || (process.platform == 'darwin' ? process.env.HOME + '/Library/Preferences' : process.env.HOME + "/.local/share");
|
||||
if (!result) {
|
||||
throw new Error("Cannot locate user data directory");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the file path for a key
|
||||
*/
|
||||
getPath(key: string): string {
|
||||
const path = require("path");
|
||||
return path.join(this.directory, key);
|
||||
}
|
||||
|
||||
async get(key: string): Promise<string | null> {
|
||||
const fs = require("fs");
|
||||
return new Promise((resolve, reject) => {
|
||||
fs.readFile(this.getPath(key), { encoding: 'utf8' }, (err: any, data: string) => {
|
||||
resolve(err ? null : data);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async set(key: string, value: string | null): Promise<void> {
|
||||
const fs = require("fs");
|
||||
|
||||
if (!this.checked) {
|
||||
await new Promise((resolve, reject) => {
|
||||
fs.mkdir(this.directory, { recursive: true }, (err: any) => {
|
||||
if (err && err.code !== 'EEXIST') {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
this.checked = true;
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
if (value === null) {
|
||||
fs.unlink(this.getPath(key), (err: any) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
fs.writeFile(this.getPath(key), value, { encoding: 'utf8' }, (err: any) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
|
@ -1,33 +0,0 @@
|
|||
import getPort from "get-port";
|
||||
import { MemoryStorage } from "./basic";
|
||||
import { basicCheck } from "./basic.test";
|
||||
import { RestRemoteStorage } from "./remote";
|
||||
import { startRestServer } from "./server";
|
||||
|
||||
export function startTestServer(): { port: () => number } {
|
||||
let app: { close: Function } | undefined;
|
||||
let port: number;
|
||||
|
||||
beforeEach(async () => {
|
||||
port = await getPort();
|
||||
app = await startRestServer(port, new MemoryStorage());
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (app) {
|
||||
await app.close();
|
||||
app = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
return { port: () => port };
|
||||
}
|
||||
|
||||
describe(RestRemoteStorage, () => {
|
||||
const server = startTestServer();
|
||||
|
||||
it("communicates with the server", async () => {
|
||||
const storage = new RestRemoteStorage("test", `http://localhost:${server.port()}`);
|
||||
await basicCheck(storage);
|
||||
});
|
||||
});
|
|
@ -1,64 +0,0 @@
|
|||
import { KeyValueStorage } from "./basic";
|
||||
|
||||
function protectKey(key: string): string {
|
||||
return encodeURIComponent(key);
|
||||
}
|
||||
|
||||
type RestClient = (method: "GET" | "POST" | "PUT" | "DELETE", uri: string, body?: string) => Promise<string | null>
|
||||
export const HEADER_REQUESTER = "X-TK-Storage-App"
|
||||
export const HEADER_REPLYIER = "X-TK-Storage-Control"
|
||||
|
||||
/**
|
||||
* Storage on a remote server, directly usable using HTTP REST verbs
|
||||
*/
|
||||
export class RestRemoteStorage implements KeyValueStorage {
|
||||
readonly appname: string
|
||||
readonly base_url: string
|
||||
readonly client: RestClient
|
||||
|
||||
constructor(appname: string, url: string) {
|
||||
this.appname = appname;
|
||||
this.base_url = (url[url.length - 1] != "/") ? url + "/" : url;
|
||||
this.client = this.createClient();
|
||||
}
|
||||
|
||||
async get(key: string): Promise<string | null> {
|
||||
key = protectKey(key);
|
||||
return await this.client("GET", key);
|
||||
}
|
||||
|
||||
async set(key: string, value: string | null): Promise<void> {
|
||||
key = protectKey(key);
|
||||
if (value === null) {
|
||||
await this.client("DELETE", key);
|
||||
} else {
|
||||
await this.client("PUT", key, value);
|
||||
}
|
||||
}
|
||||
|
||||
private createClient(): RestClient {
|
||||
const Request: typeof XMLHttpRequest = (typeof XMLHttpRequest === "undefined") ? require("xmlhttprequest").XMLHttpRequest : XMLHttpRequest;
|
||||
const client: RestClient = (method, path, body?) => new Promise((resolve, reject) => {
|
||||
const xhr = new Request();
|
||||
xhr.open(method, `${this.base_url}${path}`);
|
||||
xhr.responseType = 'text';
|
||||
xhr.setRequestHeader(HEADER_REQUESTER, this.appname);
|
||||
xhr.onload = function () {
|
||||
if (xhr.getResponseHeader(HEADER_REPLYIER) != "ok") {
|
||||
reject("storage not compatible with tk-storage");
|
||||
} else if (xhr.status == 200) {
|
||||
resolve(xhr.responseText);
|
||||
} else if (xhr.status == 404) {
|
||||
resolve(null);
|
||||
} else {
|
||||
reject(`http error ${xhr.status}: ${xhr.statusText}`);
|
||||
}
|
||||
};
|
||||
xhr.onerror = function () {
|
||||
reject("unknown error");
|
||||
};
|
||||
xhr.send(body);
|
||||
});
|
||||
return client;
|
||||
}
|
||||
}
|
|
@ -1,64 +0,0 @@
|
|||
import bodyParser from "body-parser";
|
||||
import cors from "cors";
|
||||
import express from "express";
|
||||
import { getLocalStorage } from ".";
|
||||
import { KeyValueStorage } from "./basic";
|
||||
import { HEADER_REPLYIER, HEADER_REQUESTER } from "./remote";
|
||||
|
||||
type Server = {
|
||||
close: () => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a server compliant with RestRemoteStorage
|
||||
*/
|
||||
export function startRestServer(port: number, storage: KeyValueStorage): Promise<Server> {
|
||||
const app = express();
|
||||
app.use(
|
||||
cors({ exposedHeaders: [HEADER_REPLYIER] }),
|
||||
bodyParser.text(),
|
||||
(req, res, next) => {
|
||||
if (req.header(HEADER_REQUESTER)) {
|
||||
res.set(HEADER_REPLYIER, "ok");
|
||||
next();
|
||||
} else {
|
||||
res.status(400).send();
|
||||
}
|
||||
},
|
||||
);
|
||||
app.get("*", (req, res, next) => {
|
||||
storage.get(req.path).then(result => {
|
||||
if (result === null) {
|
||||
res.status(404).send();
|
||||
} else {
|
||||
res.send(result);
|
||||
}
|
||||
}).catch(next);
|
||||
});
|
||||
app.put("*", (req, res, next) => {
|
||||
storage.set(req.path, req.body).then(() => {
|
||||
res.send();
|
||||
}).catch(next);
|
||||
});
|
||||
app.delete("*", (req, res, next) => {
|
||||
storage.set(req.path, null).then(() => {
|
||||
res.send();
|
||||
}).catch(next);
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const server = app.listen(port, () => {
|
||||
resolve({
|
||||
close: () => new Promise((resolve, reject) => {
|
||||
server.close((err) => err ? reject(err) : resolve());
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof require !== "undefined" && require.main === module) {
|
||||
startRestServer(5001, getLocalStorage("tk-storage-server")).then(() => {
|
||||
console.log(`Server running, use http://localhost:5001 to connect`);
|
||||
});
|
||||
}
|
57
testing.ts
Normal file
57
testing.ts
Normal file
|
@ -0,0 +1,57 @@
|
|||
import { KeyValueStorage, MemoryStorage } from "./basic.ts";
|
||||
import { expect } from "https://code.thunderk.net/typescript/devtools/raw/1.2.2/testing.ts";
|
||||
export {
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "https://code.thunderk.net/typescript/devtools/raw/1.2.2/testing.ts";
|
||||
import { getAvailablePort } from "https://deno.land/x/port@1.0.0/mod.ts";
|
||||
import { startRestServer } from "./server.ts";
|
||||
|
||||
/**
|
||||
* Basic high-level test suite for any kind storage
|
||||
*/
|
||||
export async function basicCheck(storage: KeyValueStorage): Promise<void> {
|
||||
expect(await storage.get("test")).toBe(null);
|
||||
await storage.set("test", "val");
|
||||
expect(await storage.get("test")).toBe("val");
|
||||
expect(await storage.get("notest")).toBe(null);
|
||||
await storage.set("test", null);
|
||||
expect(await storage.get("test")).toBe(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Temporarily disable localStorage (constructor will throw error),
|
||||
* for the duration of the callback
|
||||
*/
|
||||
export async function disableLocalStorage(
|
||||
body: () => Promise<void>,
|
||||
): Promise<void> {
|
||||
const ns = window as any;
|
||||
const removed = ns["localStorage"];
|
||||
delete ns["localStorage"];
|
||||
try {
|
||||
await body;
|
||||
} finally {
|
||||
ns["localStorage"] = removed;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a temporary test server, with memory storage
|
||||
*/
|
||||
export async function runTestServer(
|
||||
body: (url: string) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const port = await getAvailablePort();
|
||||
if (!port) {
|
||||
throw new Error("No port available for test server");
|
||||
}
|
||||
|
||||
const app = await startRestServer(port, new MemoryStorage());
|
||||
try {
|
||||
await body(`http://localhost:${port}`);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
}
|
|
@ -1,21 +1,10 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"moduleResolution": "node",
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"target": "ESNext",
|
||||
"strict": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"preserveConstEnums": true,
|
||||
"declaration": true,
|
||||
"target": "es6",
|
||||
"lib": [
|
||||
"dom",
|
||||
"es6"
|
||||
]
|
||||
},
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"src/**/*.test.ts"
|
||||
]
|
||||
"preserveConstEnums": true
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue