31 changed files with 347 additions and 9566 deletions
@ -1,2 +0,0 @@ |
|||
node_modules |
|||
.venv |
@ -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 |
@ -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 |
|||
} |
|||
} |
|||
|
@ -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 |
@ -1,67 +1,3 @@ |
|||
tk-storage |
|||
========== |
|||
# typescript/storage |
|||
|
|||
[](https://gitlab.com/thunderk/tk-storage/commits/master) |
|||
[](https://gitlab.com/thunderk/tk-storage/commits/master) |
|||
[](https://npmjs.com/tk-storage) |
|||
[](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 |
|||
``` |
|||
[](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 () => { |
@ -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"; |
@ -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" |
|||
] |
|||
} |
@ -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"); |
@ -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(); |
|||
} |
File diff suppressed because it is too large
@ -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" |
|||
] |
|||
} |
@ -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); |
|||
}); |
|||
}); |
|||
}); |
@ -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; |
|||
} |
|||
} |
@ -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; |
|||
}); |
@ -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); |
|||
}); |
|||
}); |
@ -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`); |
|||
}); |
|||
} |
@ -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 new issue