Skip to content

Commit

Permalink
feat(http, server): support native ttl (#479)
Browse files Browse the repository at this point in the history
  • Loading branch information
pi0 authored Sep 5, 2024
1 parent 329ff2b commit 69c0583
Show file tree
Hide file tree
Showing 5 changed files with 138 additions and 56 deletions.
4 changes: 3 additions & 1 deletion docs/2.drivers/http.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,12 @@ const storage = createStorage({

- `getItem`: Maps to http `GET`. Returns deserialized value if response is ok
- `hasItem`: Maps to http `HEAD`. Returns `true` if response is ok (200)
- `setItem`: Maps to http `PUT`. Sends serialized value using body
- `getMeta`: Maps to http `HEAD` (headers: `last-modified` => `mtime`, `x-ttl` => `ttl`)
- `setItem`: Maps to http `PUT`. Sends serialized value using body (`ttl` option will be sent as `x-ttl` header).
- `removeItem`: Maps to `DELETE`
- `clear`: Not supported

**Transaction Options:**

- `headers`: Custom headers to be sent on each operation (`getItem`, `setItem`, etc)
- `ttl`: Custom `ttl` (in seconds) for supported drivers. Will be mapped to `x-ttl` http header.
50 changes: 33 additions & 17 deletions src/drivers/http.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { TransactionOptions } from "../types";
import { defineDriver } from "./utils";
import { type FetchError, $fetch as _fetch } from "ofetch";
import { joinURL } from "ufo";
Expand All @@ -22,82 +23,97 @@ export default defineDriver((opts: HTTPOptions) => {
throw error;
};

const getHeaders = (
topts: TransactionOptions | undefined,
defaultHeaders?: Record<string, string>
) => {
const headers = {
...defaultHeaders,
...opts.headers,
...topts?.headers,
};
if (topts?.ttl && !headers["x-ttl"]) {
headers["x-ttl"] = topts.ttl + "";
}
return headers;
};

return {
name: DRIVER_NAME,
options: opts,
hasItem(key, topts) {
return _fetch(r(key), {
method: "HEAD",
headers: { ...opts.headers, ...topts.headers },
headers: getHeaders(topts),
})
.then(() => true)
.catch((err) => catchFetchError(err, false));
},
async getItem(key, tops = {}) {
async getItem(key, tops) {
const value = await _fetch(r(key), {
headers: { ...opts.headers, ...tops.headers },
headers: getHeaders(tops),
}).catch(catchFetchError);
return value;
},
async getItemRaw(key, topts) {
const value = await _fetch(r(key), {
headers: {
accept: "application/octet-stream",
...opts.headers,
...topts.headers,
},
headers: getHeaders(topts, { accept: "application/octet-stream" }),
}).catch(catchFetchError);
return value;
},
async getMeta(key, topts) {
const res = await _fetch.raw(r(key), {
method: "HEAD",
headers: { ...opts.headers, ...topts.headers },
headers: getHeaders(topts),
});
let mtime = undefined;
let ttl = undefined;
const _lastModified = res.headers.get("last-modified");
if (_lastModified) {
mtime = new Date(_lastModified);
}
const _ttl = res.headers.get("x-ttl");
if (_ttl) {
ttl = Number.parseInt(_ttl, 10);
}
return {
status: res.status,
mtime,
ttl,
};
},
async setItem(key, value, topts) {
await _fetch(r(key), {
method: "PUT",
body: value,
headers: { ...opts.headers, ...topts?.headers },
headers: getHeaders(topts),
});
},
async setItemRaw(key, value, topts) {
await _fetch(r(key), {
method: "PUT",
body: value,
headers: {
headers: getHeaders(topts, {
"content-type": "application/octet-stream",
...opts.headers,
...topts.headers,
},
}),
});
},
async removeItem(key, topts) {
await _fetch(r(key), {
method: "DELETE",
headers: { ...opts.headers, ...topts.headers },
headers: getHeaders(topts),
});
},
async getKeys(base, topts) {
const value = await _fetch(rBase(base), {
headers: { ...opts.headers, ...topts.headers },
headers: getHeaders(topts),
});
return Array.isArray(value) ? value : [];
},
async clear(base, topts) {
await _fetch(rBase(base), {
method: "DELETE",
headers: { ...opts.headers, ...topts.headers },
headers: getHeaders(topts),
});
},
};
Expand Down
73 changes: 38 additions & 35 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
EventHandler,
H3Event,
} from "h3";
import { Storage } from "./types";
import type { Storage, TransactionOptions, StorageMeta } from "./types";
import { stringify } from "./_utils";
import { normalizeKey, normalizeBaseKey } from "./utils";

Expand Down Expand Up @@ -74,64 +74,53 @@ export function createH3StorageHandler(
throw _httpError;
}

// GET => getItem
// GET => getItem / getKeys
if (event.method === "GET") {
if (isBaseKey) {
const keys = await storage.getKeys(key);
return keys.map((key) => key.replace(/:/g, "/"));
}

const isRaw =
getRequestHeader(event, "accept") === "application/octet-stream";

const checkNotFound = (value: any) => {
if (value === null) {
throw createError({
statusMessage: "KV value not found",
statusCode: 404,
});
}
};

if (isRaw) {
const value = await storage.getItemRaw(key);
checkNotFound(value);
return value;
} else {
const value = await storage.getItem(key);
checkNotFound(value);
return stringify(value);
const driverValue = await (isRaw
? storage.getItemRaw(key)
: storage.getItem(key));
if (driverValue === null) {
throw createError({
statusCode: 404,
statusMessage: "KV value not found",
});
}
setMetaHeaders(event, await storage.getMeta(key));
return isRaw ? driverValue : stringify(driverValue);
}

// HEAD => hasItem + meta (mtime)
// HEAD => hasItem + meta (mtime, ttl)
if (event.method === "HEAD") {
const _hasItem = await storage.hasItem(key);
event.node.res.statusCode = _hasItem ? 200 : 404;
if (_hasItem) {
const meta = await storage.getMeta(key);
if (meta.mtime) {
setResponseHeader(
event,
"last-modified",
new Date(meta.mtime).toUTCString()
);
}
if (!(await storage.hasItem(key))) {
throw createError({
statusCode: 404,
statusMessage: "KV value not found",
});
}
setMetaHeaders(event, await storage.getMeta(key));
return "";
}

// PUT => setItem
if (event.method === "PUT") {
const isRaw =
getRequestHeader(event, "content-type") === "application/octet-stream";
const topts: TransactionOptions = {
ttl: Number(getRequestHeader(event, "x-ttl")) || undefined,
};
if (isRaw) {
const value = await readRawBody(event, false);
await storage.setItemRaw(key, value);
await storage.setItemRaw(key, value, topts);
} else {
const value = await readRawBody(event, "utf8");
if (value !== undefined) {
await storage.setItem(key, value);
await storage.setItem(key, value, topts);
}
}
return "OK";
Expand All @@ -150,6 +139,20 @@ export function createH3StorageHandler(
});
}

function setMetaHeaders(event: H3Event, meta: StorageMeta) {
if (meta.mtime) {
setResponseHeader(
event,
"last-modified",
new Date(meta.mtime).toUTCString()
);
}
if (meta.ttl) {
setResponseHeader(event, "x-ttl", `${meta.ttl}`);
setResponseHeader(event, "cache-control", `max-age=${meta.ttl}`);
}
}

/**
* This function creates a node-compatible handler for your custom storage server.
*
Expand Down
2 changes: 2 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@ export type Unwatch = () => MaybePromise<void>;
export interface StorageMeta {
atime?: Date;
mtime?: Date;
ttl?: number;
[key: string]: StorageValue | Date | undefined;
}

// TODO: type ttl
export type TransactionOptions = Record<string, any>;

export interface Driver<OptionsT = any, InstanceT = any> {
Expand Down
65 changes: 62 additions & 3 deletions test/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@ import { listen } from "listhen";
import { $fetch } from "ofetch";
import { createStorage } from "../src";
import { createStorageServer } from "../src/server";
import fs from "../src/drivers/fs.ts";
import fsDriver from "../src/drivers/fs.ts";
import httpDriver from "../src/drivers/http.ts";

describe("server", () => {
it("basic", async () => {
const storage = createStorage();
const storage = createTestStorage();
const storageServer = createStorageServer(storage, {
authorize(req) {
if (req.type === "read" && req.key.startsWith("private:")) {
Expand All @@ -23,6 +24,10 @@ describe("server", () => {
const fetchStorage = (url: string, options?: any) =>
$fetch(url, { baseURL: serverURL, ...options });

const remoteStorage = createStorage({
driver: httpDriver({ base: serverURL }),
});

expect(await fetchStorage("foo/", {})).toMatchObject([]);

await storage.setItem("foo/bar", "bar");
Expand Down Expand Up @@ -56,12 +61,17 @@ describe("server", () => {
statusMessage: "Unauthorized Read",
});

// TTL
await storage.setItem("ttl", "ttl", { ttl: 1000 });
expect(await storage.getMeta("ttl")).toMatchObject({ ttl: 1000 });
expect(await remoteStorage.getMeta("ttl")).toMatchObject({ ttl: 1000 });

await close();
});

it("properly encodes raw items", async () => {
const storage = createStorage({
driver: fs({ base: "./test/fs-storage" }),
driver: fsDriver({ base: "./test/fs-storage" }),
});
const storageServer = createStorageServer(storage);
const { close, url: serverURL } = await listen(storageServer.handle, {
Expand Down Expand Up @@ -91,3 +101,52 @@ describe("server", () => {
await close();
});
});

function createTestStorage() {
const data = new Map<string, string>();
const ttl = new Map<string, number>();
const storage = createStorage({
driver: {
hasItem(key) {
return data.has(key);
},
getItem(key) {
return data.get(key) ?? null;
},
getItemRaw(key) {
return data.get(key) ?? null;
},
setItem(key, value, opts) {
data.set(key, value);
if (opts?.ttl) {
ttl.set(key, opts.ttl);
}
},
setItemRaw(key, value, opts) {
data.set(key, value);
if (opts?.ttl) {
ttl.set(key, opts.ttl);
}
},
getMeta(key) {
return {
ttl: ttl.get(key),
};
},
removeItem(key) {
data.delete(key);
},
getKeys() {
return [...data.keys()];
},
clear() {
data.clear();
},
dispose() {
data.clear();
},
},
});

return storage;
}

0 comments on commit 69c0583

Please sign in to comment.