Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(http, server): support native ttl #479

Merged
merged 2 commits into from
Sep 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;
}
Loading